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

ComplexityMeaningTypical source1e6 items
O(1)constantmap.get, obj.key, arr[i], pushinstant
O(log n)halvingbinary search over sorted dataabout 20 steps
O(n)one passfilter, find, includes, a single loopfine
O(n log n)sortsort, toSortednoticeable, acceptable
O(n^2)nested passincludes or find inside a loophangs the tab
O(2^n)branching recursionnaive fibonacci, subset generationnever 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.

  1. Measure on the slowest device you support, not your laptop. A ten times gap is normal.
  2. Run the work enough times to exceed timer noise, and discard the first run (it includes compilation).
  3. Compare like with like: same input size, same data shape, same page state.
  4. Use the profiler for "where does the time go" and performance.now for "did my change help".
  5. 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: 25

Memoisation 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.stringify keys 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 updates

Debounce 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 methodWhy it is expensive
offsetTop, offsetHeight, offsetWidthflushes pending style and layout
getBoundingClientRect()same, and the most common offender
scrollTop, scrollHeightsame
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.

BeliefReality in 2026
for loops beat forEach meaningfullythe difference is noise unless the body is trivial and n is huge
++i is faster than i++identical after compilation
avoid try/catch, it deoptimisestrue in 2012, not true today
string concatenation in a loop is slowengines use ropes, it is fine
arrow functions are slower than functionno measurable difference
Map is always faster than an objectfaster for frequent add and delete, similar for fixed shapes
objects with the same keys are interchangeableinsertion 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 getElementsByClassName returns 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 is false. That same insertion order defines the object shape, so these two objects get different hidden classes. A function reading .x from 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 WeakMap is 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 find or includes inside 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.
  • delete on 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, for versus forEach) matter. They do not.

Takeaways

  • Four causes cover most real problems: bad complexity, repeated work, forced layout, too much per frame.
  • Measure with performance.now or marks and measures, warm up first, and keep the measurement in the repo.
  • Turning a nested search into a Map lookup 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.