Performance and Optimisation
Mental model: Performance work is measurement first and cleverness last: the engine is faster than you at everything except the things you asked it to do a million times.
Level: advanced · about 22 minutes
Almost every performance problem in front-end JavaScript is one of four things: an algorithm that grows faster than the data, work repeated that could have been remembered, layout forced in a loop, or too much work in one frame. None of them is fixed by micro-optimising syntax. All of them are found by measuring.
Big-O, only as much as you need
| Complexity | Meaning | Typical source | 1e6 items |
|---|---|---|---|
| O(1) | constant | map.get, obj.key, arr[i], push | instant |
| O(log n) | halving | binary search over sorted data | about 20 steps |
| O(n) | one pass | filter, find, includes, a single loop | fine |
| O(n log n) | sort | sort, toSorted | noticeable, acceptable |
| O(n^2) | nested pass | includes or find inside a loop | hangs the tab |
| O(2^n) | branching recursion | naive fibonacci, subset generation | never finishes |
const users = Array.from({ length: 2000 }, (_, i) => ({ id: i, name: 'u' + i }));
const orders = Array.from({ length: 2000 }, (_, i) => ({ userId: (i * 7) % 2000, total: i }));
// O(n * m): for every order, scan every user
const slow = () => orders.map((o) => ({ ...o, user: users.find((u) => u.id === o.userId) }));
// O(n + m): index once, then look up
const fast = () => {
const byId = new Map(users.map((u) => [u.id, u]));
return orders.map((o) => ({ ...o, user: byId.get(o.userId) }));
};
const time = (fn) => {
const start = performance.now();
fn();
return performance.now() - start;
};
const slowMs = time(slow);
const fastMs = time(fast);
console.log('nested find:', slowMs.toFixed(1) + 'ms');
console.log('map lookup: ', fastMs.toFixed(1) + 'ms');
console.log('same result:', JSON.stringify(slow()[0]) === JSON.stringify(fast()[0]));The one refactor that matters most often: a lookup instead of a nested search.
Measuring properly
performance.mark('parse:start');
const data = Array.from({ length: 50000 }, (_, i) => ({ id: i, score: (i * 37) % 100 }));
performance.mark('parse:end');
performance.mark('sum:start');
const total = data.reduce((sum, row) => sum + row.score, 0);
performance.mark('sum:end');
performance.measure('parse', 'parse:start', 'parse:end');
performance.measure('sum', 'sum:start', 'sum:end');
for (const entry of performance.getEntriesByType('measure')) {
console.log(entry.name, entry.duration.toFixed(2) + 'ms');
}
console.log('total:', total);
performance.clearMarks();
performance.clearMeasures();The performance API gives you a monotonic clock, named marks and durations.
- Measure on the slowest device you support, not your laptop. A ten times gap is normal.
- Run the work enough times to exceed timer noise, and discard the first run (it includes compilation).
- Compare like with like: same input size, same data shape, same page state.
- Use the profiler for "where does the time go" and
performance.nowfor "did my change help". - Keep the measurement in the codebase. An optimisation with no test regresses within a month.
Remembering instead of recomputing
let naiveCalls = 0;
const fib = (n) => {
naiveCalls += 1;
return n < 2 ? n : fib(n - 1) + fib(n - 2);
};
let memoCalls = 0;
const memo = new Map();
const fastFib = (n) => {
if (memo.has(n)) return memo.get(n);
memoCalls += 1;
const value = n < 2 ? n : fastFib(n - 1) + fastFib(n - 2);
memo.set(n, value);
return value;
};
console.log(fib(24), 'calls:', naiveCalls); // -> 46368 calls: 92735
console.log(fastFib(24), 'calls:', memoCalls); // -> 46368 calls: 25Memoisation turns exponential recursion into linear, and this is the classic demo.
memoise when- the function is pure, called repeatedly with the same arguments, and expensive
do not memoise when- arguments are almost always new, or the function is cheaper than the lookup
always decide- the eviction policy. An unbounded cache is a memory leak with good intentions
key carefully- objects key by identity.
JSON.stringifykeys are correct but not free
Debounce and throttle
Debounce: wait for quiet
function debounce(fn, wait) {
let id = null;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), wait);
};
}
// search input, resize end,
// autosave after typing stops
Throttle: at most once per window
function throttle(fn, wait) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last < wait) return;
last = now;
fn(...args);
};
}
// scroll position, pointermove,
// progress updatesDebounce collapses a burst into one call at the end. Throttle lets a burst through at a fixed rate. Using debounce for scroll means nothing happens while the user scrolls, which is almost never what you want.
Layout thrashing and DOM batching
// Thrashing: one forced layout per row
for (const row of rows) {
row.style.height = row.offsetHeight + 10 + 'px'; // write then read then write...
}
// Batched: all reads, then all writes, one layout
const heights = rows.map((row) => row.offsetHeight); // read phase
rows.forEach((row, i) => {
row.style.height = heights[i] + 10 + 'px'; // write phase
});Reading geometry after a write forces the browser to lay out immediately.
| Property or method | Why it is expensive |
|---|---|
offsetTop, offsetHeight, offsetWidth | flushes pending style and layout |
getBoundingClientRect() | same, and the most common offender |
scrollTop, scrollHeight | same |
getComputedStyle(el) | flushes style resolution |
focus(), scrollIntoView() | may force layout and scrolling work |
Object shapes and hidden classes
V8 does not store your object as a hash map if it can avoid it. It gives each distinct "shape" (the ordered set of property names) a hidden class, and compiles property access into a fixed offset lookup. Two objects with the same properties added in the same order share a shape, and code that only ever sees one shape gets the fastest possible access.
{ } -> shape A
{ x } -> shape B const p1 = { x: 1, y: 2 }; // A -> B -> C
{ x, y } -> shape C const p2 = { x: 3, y: 4 }; // same path, shape C
{ x, y, z } -> shape D p2.z = 5; // now shape D
{ y } -> shape E const p3 = { y: 1, x: 2 }; // different ORDER
{ y, x } -> shape F // shape F, not C. Same keys, different shape.
Shape-unstable
function makePoint(x, y, label) {
const p = {};
p.x = x;
p.y = y;
if (label) p.label = label; // two shapes
return p;
}
// later, somewhere else
point.z = 0; // a third shape
delete point.y; // dictionary mode
Shape-stable
function makePoint(x, y, label = null) {
return { x, y, label }; // one shape, always
}
// need to remove a value?
point.label = null; // keep the key
// or rebuild:
const next = { ...point, y: 0 };Initialise every field in the constructor or literal, in the same order, even when the value is null. Never delete a property from a hot object: it can push the object into dictionary mode, which is dramatically slower for reads.
// Same keys, same order: one shape.
const mono = Array.from({ length: 20000 }, (_, i) => ({ x: i, y: i * 2 }));
// Five different shapes cycling through the same call site.
const shapes = [
(i) => ({ x: i, y: i }),
(i) => ({ y: i, x: i }),
(i) => ({ x: i, y: i, z: i }),
(i) => ({ a: i, x: i, y: i }),
(i) => ({ x: i, y: i, label: 'n' + i }),
];
const mega = Array.from({ length: 20000 }, (_, i) => shapes[i % 5](i));
const sumX = (list) => {
let total = 0;
for (const item of list) total += item.x;
return total;
};
const time = (fn, arg) => {
fn(arg); // warm up, discard
const start = performance.now();
const result = fn(arg);
return [performance.now() - start, result];
};
const [monoMs, a] = time(sumX, mono);
const [megaMs, b] = time(sumX, mega);
console.log('monomorphic:', monoMs.toFixed(2) + 'ms');
console.log('megamorphic:', megaMs.toFixed(2) + 'ms');
console.log('both computed a sum:', a > 0 && b > 0);A call site that sees one shape is monomorphic. Past about four it goes megamorphic.
- monomorphic: the call site has seen one shape. Inline cache hit, fastest path.
- polymorphic: two to four shapes. Still cached, a small check per access.
- megamorphic: five or more. The cache is abandoned and every access goes through a generic lookup.
- This only matters in code that runs thousands of times. In a click handler it is irrelevant.
const a = [1, 2, 3];
delete a[1];
console.log(a.length, a[1], 1 in a, a.map((n) => n * 2));delete on an array removes the element without changing length, leaving a hole. Reading the hole gives undefined, but 1 in a is false because there is no property there at all. map skips holes and preserves them in the result, so you get a sparse array that logs as an empty item. Beyond the semantics, holes matter for speed: the engine moves the array from a packed elements kind to a holey one, and every read then needs a prototype chain check. Use splice or toSpliced to remove, or assign null to blank a slot.
| Belief | Reality in 2026 |
|---|---|
for loops beat forEach meaningfully | the difference is noise unless the body is trivial and n is huge |
++i is faster than i++ | identical after compilation |
avoid try/catch, it deoptimises | true in 2012, not true today |
| string concatenation in a loop is slow | engines use ropes, it is fine |
arrow functions are slower than function | no measurable difference |
Map is always faster than an object | faster for frequent add and delete, similar for fixed shapes |
| objects with the same keys are interchangeable | insertion order changes the shape, and shape drives access speed |
Interactive visualiser: complexity. Enable JavaScript to use it.
Try it yourself
A benchmark harness that does not lie
function bench(candidates, { runs = 5, size = 20000 } = {}) {
const input = Array.from({ length: size }, (_, i) => i);
const results = [];
for (const [name, fn] of Object.entries(candidates)) {
fn(input); // warm up, do not measure
const times = [];
for (let r = 0; r < runs; r++) {
const start = performance.now();
const out = fn(input);
times.push(performance.now() - start);
if (out === undefined) throw new Error(name + ' returned nothing, the engine may delete it');
}
times.sort((a, b) => a - b);
results.push({ name, best: +times[0].toFixed(2), median: +times[Math.floor(runs / 2)].toFixed(2) });
}
return results;
}
console.log(bench({
forOf: (arr) => { let s = 0; for (const n of arr) s += n; return s; },
forIndex: (arr) => { let s = 0; for (let i = 0; i < arr.length; i++) s += arr[i]; return s; },
reduce: (arr) => arr.reduce((a, n) => a + n, 0),
}));
Add a warm-up phase and report the median instead of the mean. Then make one of the candidates return a value you accumulate, and see whether the timing changes.
Count the shapes you create
// A stand-in for hidden classes: the ordered key list is the shape.
const shapeOf = (obj) => Object.keys(obj).join(',');
function looseFactory(i) {
const o = {};
o.id = i;
if (i % 2 === 0) o.even = true;
o.value = i * 2;
if (i % 3 === 0) o.tag = 'third';
return o;
}
function tightFactory(i) {
return { id: i, even: i % 2 === 0, value: i * 2, tag: i % 3 === 0 ? 'third' : null };
}
const count = (factory) => {
const shapes = new Set();
for (let i = 0; i < 60; i++) shapes.add(shapeOf(factory(i)));
return [...shapes];
};
console.log('loose shapes:', count(looseFactory));
console.log('tight shapes:', count(tightFactory));
Rewrite looseFactory so every object has the same keys in the same order, and confirm the shape count drops to one. Then time a sum over 100000 of each.
Exercises
Memoise with a bounded cache
Write memoize(fn, { key, maxSize } = {}). Cache results in insertion order. key builds a cache key from the arguments and defaults to joining them with \u0000. maxSize (default Infinity) evicts the least recently used entry when exceeded, where reading an entry counts as using it. Expose size, clear() and has(...args) on the returned function. undefined results must be cached like any other value.
A testable debounce
Write debounce(fn, wait, timers) where timers defaults to { setTimeout, clearTimeout } so tests can inject a fake clock. Calls during the wait window collapse into one call at the end, using the most recent arguments. Add cancel() which forgets a pending call, flush() which runs it immediately, and pending() which reports whether one is queued.
Check yourself
- A page renders 5000 rows and each row does
document.querySelectorAll('.row')to find its siblings. What is the complexity and the fix? - O(n squared), query once outside the loop and pass the result in — Each of the n rows scans the whole document, so it is n times n work. Hoisting the query out of the loop makes it one scan plus n cheap operations. Swapping to
getElementsByClassNamereturns a live collection which is not obviously cheaper and re-queries on read, so it does not address the shape of the problem. - Which is the correct use of debounce rather than throttle?
- sending a search request as the user types — Search wants one request after typing stops, which is exactly debounce. The other three need regular updates during the interaction, so debouncing them would freeze the UI until the user stopped moving. Rule of thumb: debounce for "when they are done", throttle for "while they are doing it".
- What does this print, and why does it matter for speed?
false, and the two objects also have different hidden classes — Own string keys are reported in insertion order, so'xy'and'yx'differ and the comparison isfalse. That same insertion order defines the object shape, so these two objects get different hidden classes. A function reading.xfrom both sees two shapes: still fast, but add three more variants at that call site and the inline cache goes megamorphic.- You add memoisation to a function and memory grows without limit. What was missing?
- an eviction policy: a size cap, a TTL, or weak keys — An unbounded cache is a leak by design: every distinct argument set adds an entry that is never removed. Cap the size (LRU), expire entries, or key weakly on objects so entries can be collected. A
WeakMapis one implementation of that idea but only works with object keys, so it is not a general answer.
Common mistakes
- Optimising before profiling, then defending the change with intuition.
- Benchmarking without a warm-up run, so you time the compiler.
- Benchmarking work whose result is unused, which the engine may delete entirely.
- A nested
findorincludesinside a loop, which is the most common accidental O(n squared). - An unbounded memo cache.
- Debouncing something that needs continuous updates, such as scroll.
- Alternating geometry reads and style writes in a loop.
deleteon a hot object or array, which changes the shape or creates holes.- Building the same logical object with different key orders in different code paths.
- Assuming micro-syntax choices (
++i,forversusforEach) matter. They do not.
Takeaways
- Four causes cover most real problems: bad complexity, repeated work, forced layout, too much per frame.
- Measure with
performance.nowor marks and measures, warm up first, and keep the measurement in the repo. - Turning a nested search into a
Maplookup is the single highest-value refactor you will make. - Memoise pure, expensive, repeatedly-called functions, and always define an eviction policy.
- Debounce waits for quiet, throttle limits the rate. Injecting the timer makes both testable.
- Batch DOM reads then writes. Any geometry read flushes pending layout.
- Property insertion order defines an object shape, and stable shapes keep call sites monomorphic.
- Past four shapes a call site goes megamorphic and loses its inline cache. Only hot code cares.