JavaScript performance tips I actually use
Six habits from ten years of small, fast libraries, each one shown in real code and measured on two engines.
People ask me for performance tips more than anything else. I have never written them down, partly because most code does not need them, and partly because the honest answer starts with "it depends" and nobody likes that answer.
So here is the list I actually use. Each tip comes from a library I maintain, with the real code. Each one has a number next to it, and the number comes from a benchmark I ran for this post, not from memory.
#Measure first
Every tip below can be wrong for your code. Engines change, and a trick that won in 2018 may lose today. The only defense is to measure, in more than one engine, with a tool that resists the usual microbenchmark lies: dead code elimination, a cold or overly warm JIT, and too few samples. I wrote a separate post about how I do that: How to benchmark honestly.
The numbers in this post come from mitata on an Apple M1 Max, with Node 26.11.1 (V8) and Bun 1.4.3 (JavaScriptCore). Each result is the average time for one iteration. Lower is better.
Each case compares a slow way with a fast way:
- Hex-encode 1024 bytes:
toString(16), then a lookup table. - Count leading dashes: a regex, then
charCodeAt. - Sum doubled evens (1000):
filter.map.reduce, then aforloop. - Join 8 class names:
filter.join, then+=. - Read
.xfrom 1000 objects: 8 shapes, then 1 shape.
| case | Node slow | Node fast |
|---|---|---|
| 1 | 21.16 µs | 4.44 µs |
| 2 | 193.94 ns | 38.66 ns |
| 3 | 5.59 µs | 770.11 ns |
| 4 | 120.31 ns | 47.93 ns |
| 5 | 3.21 µs | 703.13 ns |
| case | Bun slow | Bun fast |
|---|---|---|
| 1 | 20.40 µs | 3.39 µs |
| 2 | 166.49 ns | 27.97 ns |
| 3 | 3.02 µs | 753.35 ns |
| 4 | 70.24 ns | 56.72 ns |
| 5 | 2.54 µs | 999.17 ns |
Look at the last two cases before you trust the first three. The string-building gap on Node is 2.5×. On Bun it is almost gone. The same code, the same machine, and a different answer. That is why "measure first" comes first.
#Keep object shapes stable
Engines give each object a hidden "shape" from its keys and the order they were added. A property read at one call site that always sees one shape is fast: the engine remembers where .x lives and goes straight there. When the same call site sees many shapes, the engine has to look it up, and after a few shapes it stops trying to be clever.
In the table, the same loop reads .x from 1000 objects. With one shape, it takes 703 ns on Node. With eight shapes, it takes 3.21 µs. Nothing else changed.
In practice, this means a few habits:
- Create objects with all their keys at once, in the same order. Do not add keys later.
- Do not
deletekeys. Set them toundefinedinstead. - Keep functions monomorphic in hot paths. One function that takes strings and another that takes arrays is often faster than one function that takes both.
dequal takes the last one seriously. It checks constructor first and then handles each type in its own branch, so the work inside each branch always sees one kind of value:
if (foo && bar && (ctor = foo.constructor) === bar.constructor) {
if (ctor === Date) return foo.getTime() === bar.getTime();
if (ctor === RegExp) return foo.toString() === bar.toString();
if (ctor === Array) {
if ((len = foo.length) === bar.length) {
while (len-- && dequal(foo[len], bar[len]));
}
return len === -1;
}
// ...
}
#Use plain loops and exit early, in hot paths
I like map, filter, and reduce. I use them in application code every day. But each one is a function call per item and a new array per step. In the table, filter().map().reduce() over 1000 numbers takes 5.59 µs on Node. A for loop that does the same work takes 770 ns, and it allocates nothing.
The other half of this tip is the early exit. Look at the array branch of dequal above:
while (len-- && dequal(foo[len], bar[len]));
return len === -1;
It stops at the first item that does not match. every() would also stop early, but most chains do not: a filter always walks the whole array, even when you only wanted to know if one item exists. The fastest work is the work you skip.
This tip applies to hot paths only. If a loop runs once per page load over twelve items, write whatever reads best.
#Precompute with lookup tables and buffers
uid makes random hexadecimal IDs. The obvious way is to call toString(16) on each byte and pad it. uid builds every possible two-character string once, when the module loads:
var IDX = 256,
HEX = [],
SIZE = 256,
BUFFER;
while (IDX--) HEX[IDX] = (IDX + 256).toString(16).substring(1);
(IDX + 256).toString(16).substring(1) is a small trick: adding 256 always gives three hex digits, so dropping the first one gives you a zero-padded pair with no padStart call.
Then it goes one step further. It does not make an ID per call. It fills a 512-character buffer and hands out slices of it, and it only refills when the buffer runs out:
export function uid(len) {
var i = 0,
tmp = len || 11;
if (!BUFFER || IDX + tmp > SIZE * 2) {
for (BUFFER = "", IDX = 0; i < SIZE; i++) {
BUFFER += HEX[(Math.random() * 256) | 0];
}
}
return BUFFER.substring(IDX, IDX++ + tmp);
}
In the table, encoding 1024 bytes with the lookup table takes 4.44 µs on Node, against 21.16 µs for toString(16).padStart(2, '0'). The table costs 256 short strings, once.
hexoid takes the buffer idea further. It makes one random prefix and then appends a counter from the same table, so most calls are one string concatenation.
#Read characters with charCodeAt before you reach for a regex
mri parses command-line flags. It needs to know how many dashes start each argument. A regex like /^-*/ is the natural choice. mri does this instead:
for (j = 0; j < arg.length; j++) {
if (arg.charCodeAt(j) !== 45) break; // "-"
}
45 is the code for -. The loop compares numbers, allocates nothing, and stops at the first other character. It also finds = in --key=value the same way, with 61.
In the table, counting leading dashes for eight arguments takes 193.94 ns with a regex and 38.66 ns with charCodeAt on Node. On Bun it is 166.49 ns against 27.97 ns.
Regex engines are very good. But a regex has a fixed setup cost, and a match result is an object. For a check of one or two characters, that cost is most of the work.
#Build strings directly
clsx builds a class name string. The common way is to collect the parts in an array, then filter(Boolean).join(' '). clsx appends to one string and only adds a space when the string already has content:
for (y in mix) {
if (mix[y]) {
str && (str += " ");
str += y;
}
}
No array, no filter, no join. Engines handle += on strings well, because they build a rope internally and only flatten it when you read it.
This is also the tip that shows why you measure. On Node, += takes 47.93 ns against 120.31 ns for filter.join. On Bun, it takes 56.72 ns against 70.24 ns. It is still a win, but a small one. If I had only tested one engine, I would have told you the wrong size.
#Do not allocate in the hot path
Most of the tips above are this tip in different clothes. The garbage collector is fast, but nothing is faster than memory you never asked for. In hot code, I look for:
- Closures made inside loops. A callback created per item is an allocation per item. Define it once, outside the loop.
- Temporary arrays.
[a, b].includes(x)makes an array to answer a question thatx === a || x === banswers for free. - Spread and rest.
{ ...obj, key }andfn(...args)copy. That is fine once. It is not fine a million times. - Work that can move to setup time.
hexoidreturns a function. All the setup happens once, when you callhexoid(), and the function it returns does almost nothing.
export function hexoid(len) {
len = len || 16;
var str = "",
num = 0;
return function () {
if (!str || num === 256) {
str = "";
num = ((1 + len) / 2) | 0;
while (num--) str += HEX[(256 * Math.random()) | 0];
str = str.substring((num = 0), len - 2);
}
return str + HEX[num++];
};
}
#When not to do any of this
Almost always.
These tips are for code that runs millions of times: a router match, a class name helper inside every render, a deep-equal check inside every state update. That is the kind of code I write for libraries, and it is why I care. Your request handler that calls a database is not that kind of code. A for loop will not save you from a 40 ms query.
Fast code does not have to be ugly code, either. Every example above is readable once you know the one trick it uses. When a change only makes something faster by making it a puzzle, I usually do not make it. I wrote more about that trade in Small on purpose.
Make it correct, make it clear, then measure. Only the third step tells you if there is a fourth.
Most of my libraries are fast because they do less, not because of anything on this list. This list is for the last 2×, after you have already removed everything you do not need.