JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Common performance traps and measuring first
Measure a slow code path with performance.now() before changing it, and spot the real traps: nested scans, spread copies in loops, and layout thrashing.
What you will learn
- Time a code path with performance.now(), dropping the warm-up run and taking the best
- Spot accidental O(n²): an array scan or a spread copy inside a per-item loop
- Batch DOM reads before writes so layout is recalculated once, not once per item
- Name hot paths with performance.mark/measure so they show up in DevTools
Understanding Common performance traps and measuring first
Every interaction has a budget: roughly 16 ms of work per frame for smooth animation, and a few hundred milliseconds before a click feels laggy. When something blows that budget, the time sits in a small number of buckets — waiting on the network, your JavaScript, and the browser's own style, layout and paint work — and reading the source tells you what happens, not how many times it happens. That is why guessing is unreliable: the change you feel confident about is usually a constant factor, while the real cost is one line that quietly runs a million times. Get a number first, from performance.now() around the suspect path or from a profiler recording, and only then decide what to edit.
The traps that matter share one shape: work per item that touches every other item, so the total grows with n² while the code still reads as a single loop. `list.filter(x => other.includes(x.id))` scans `other` once per element; `items.reduce((acc, x) => [...acc, x], [])` copies the whole accumulator on every step; `queue.shift()` in a while loop shifts the remaining elements each time; `container.innerHTML += row` re-serialises and re-parses everything already there; reading `el.offsetHeight` right after a style write forces the browser to redo layout before it can answer. Ten rows hides all of that, ten thousand freezes the tab. By contrast, `for` versus `forEach`, or caching `.length`, are constant factors the JIT has already flattened, which is why those rewrites so rarely move the number.
A measurement is only useful when it is above the noise. Run the candidate enough times that the elapsed time is comfortably larger than the clock's resolution, discard the first run because it pays for parsing and optimisation, and report the best or the median rather than the mean, which gets dragged around by GC pauses and other processes. Use the returned value for something so the engine cannot treat the work as dead, change one variable at a time, and re-run the identical harness after the fix. If the number did not move, the change was not an optimisation — put the simpler code back.
function bestOf(fn, runs = 3) {
fn(); // warm-up: the first call also pays for parsing and optimising
let ms = Infinity;
let value;
for (let i = 0; i < runs; i++) {
const start = performance.now();
value = fn(); // keep the result so the work cannot be skipped
ms = Math.min(ms, performance.now() - start);
}
return { ms, value };
}
const nums = Array.from({ length: 10000 }, (_, i) => i);
const spread = bestOf(() => nums.reduce((acc, n) => [...acc, n * 2], []));
const loop = bestOf(() => {
const out = [];
for (const n of nums) out.push(n * 2);
return out;
});
console.log('same result:', spread.value.length === loop.value.length && spread.value.at(-1) === loop.value.at(-1));
console.log('loop at least 20x faster:', spread.ms > loop.ms * 20);
console.log('elements copied by the spread version:', (nums.length * (nums.length - 1)) / 2);Get a number before you change anything, then hunt for work that grows with the input rather than for prettier syntax.
Worked examples
Count operations, not milliseconds
Instrumenting a nested scan and its Set replacement shows the growth as an exact, machine-independent number.
function intersectScan(xs, ys) {
let comparisons = 0;
const out = [];
for (const x of xs) {
for (const y of ys) {
comparisons++;
if (x === y) { out.push(x); break; }
}
}
return { out, comparisons };
}
function intersectSet(xs, ys) {
const seen = new Set(ys);
let lookups = 0;
const out = [];
for (const x of xs) {
lookups++;
if (seen.has(x)) out.push(x);
}
return { out, lookups };
}
const xs = Array.from({ length: 5000 }, (_, i) => i);
const ys = Array.from({ length: 5000 }, (_, i) => i + 2500);
const scanned = intersectScan(xs, ys);
const hashed = intersectSet(xs, ys);
console.log('matches:', scanned.out.length, hashed.out.length);
console.log('scan comparisons:', scanned.comparisons);
console.log('set lookups:', hashed.lookups);Example explained
Line 1The inner `for (const y of ys)` restarts for every `x`, so the comparison count grows with `xs.length * ys.length` — this is exactly what a hidden `includes()` or `find()` inside a loop does.
Line 2`break` only helps on hits: the 2,500 values of `x` below 2500 have no match, so each pays a full 5,000-element scan.
Line 3`new Set(ys)` is one pass over `ys`, after which `seen.has(x)` is a hash lookup, turning 15,626,250 comparisons into 5,000 lookups.
Line 4Both functions return the same 2,500 matches, which is the proof that the faster version is a replacement and not a different answer.
Marks that survive into DevTools
performance.mark and performance.measure label a code path so its duration is readable from script and visible in the profiler timeline.
performance.mark('build:start');
const rows = [];
for (let i = 0; i < 5000; i++) rows.push({ id: i, label: 'row ' + i });
performance.mark('build:end');
performance.measure('build rows', 'build:start', 'build:end');
const entry = performance.getEntriesByName('build rows', 'measure')[0];
console.log(entry.entryType, '/', entry.name);
console.log('rows built:', rows.length);
console.log('duration is a millisecond number:', typeof entry.duration === 'number' && entry.duration >= 0);Example explained
Line 1`performance.mark` only records a named timestamp; nothing is timed until `performance.measure` joins two marks into an entry with a `duration`.
Line 2`getEntriesByName('build rows', 'measure')` reads the entry back from the buffer, which behaves the same in a browser and in Node 18+.
Line 3The measure appears in the DevTools performance timeline under its own name, so a recording shows your label next to the browser's own layout and paint work.
Line 4The code prints `duration >= 0` rather than the value, because the value differs on every run and every machine.
Interleaved DOM reads force layout
A model of the browser's dirty-layout flag shows why the same DOM operations cost 50 layout passes or 1, depending only on ordering.
const layout = {
dirty: false,
recalcs: 0,
writeStyle() { this.dirty = true; },
readHeight() {
if (this.dirty) { this.recalcs++; this.dirty = false; }
return 20;
}
};
const items = Array.from({ length: 50 }, (_, i) => i);
items.forEach(() => {
layout.writeStyle();
layout.readHeight();
});
console.log('interleaved layout passes:', layout.recalcs);
layout.recalcs = 0;
layout.dirty = false;
const heights = items.map(() => layout.readHeight());
items.forEach(() => layout.writeStyle());
layout.readHeight(); // the pass the browser does before the next paint
console.log('batched layout passes:', layout.recalcs);
console.log('heights measured:', heights.length);Example explained
Line 1`writeStyle` marks layout dirty and `readHeight` counts a recalculation only when it reads while dirty — that is what `offsetHeight`, `getBoundingClientRect` and `scrollTop` trigger on a real element.
Line 2The first loop writes then reads on each of the 50 items, so every iteration forces its own synchronous layout: 50 passes.
Line 3The second version reads all 50 heights while layout is still clean, then does all the writes, leaving only the browser's own pass before painting: 1.
Line 4The number of DOM operations is identical in both versions, so the cost came from the ordering, not from the amount of DOM code.
Important notes
Use performance.now() for durations: it is a monotonic high-resolution clock, while Date.now() is wall-clock time that can be coarse and can jump if the system clock changes. Some browsers deliberately round the clock (Firefox to 1 ms by default), another reason to time a batch of iterations rather than one call.
The examples print comparisons and operation counts instead of raw milliseconds because the raw values depend on machine, engine and run; a timing result is only evidence for the environment it was taken in, so confirm wins in the browsers your users actually run.
Common mistakes
Optimising by feel: swapping forEach for an indexed for loop and hoisting arr.length while an includes() inside the loop keeps the function quadratic, so the code gets uglier and the 900 ms stays 900 ms.
Timing a single call of a fast function: one run lands inside the clock's noise and also pays for first-call compilation, so a genuine 5x win looks like jitter and the wrong version ships.
Benchmarking against the three-row test fixture: n² behaviour is invisible at n = 3 and only appears on a user's 8,000-row export, where the tab locks up.
Try it yourself
Change, predict, then run
In a browser editor, render 2,000 list items twice: once with `list.innerHTML += '<li>' + text + '</li>'` inside the loop, once by joining the strings and assigning `innerHTML` a single time after the loop. Time both with performance.now(), print the ratio, and explain the ratio in terms of how much HTML gets re-parsed.
Open the JavaScript workspaceCheck your understanding
A table render takes 9 ms for 500 rows and 900 ms for 5,000 rows. Which change is most likely to move that 900 ms?
- Replace the forEach over rows with an indexed for loop and hoist rows.length
- Minify and gzip the bundle so the script downloads and parses faster
- Build a Set of the selected ids once before the loop and replace the per-row ids.includes(id) check
- Wrap the render in requestAnimationFrame so it does not block the click handler
Show answer
Ten times the data cost a hundred times the time, which is the signature of quadratic work: something inside the per-row loop walks another whole collection. Hoisting rows.length and switching to an indexed for loop only touch constant factors the JIT has already optimised, so a 900 ms render stays roughly 900 ms; replacing the per-row linear scan with one Set built up front removes the growth itself. Minification changes load time, not this loop, and requestAnimationFrame only moves the same 900 ms of blocking work to a different moment.