Lesson 29 of 30

Performance Optimization

Measure First

The first rule of performance work is that you cannot fix what you have not measured. Intuition about what is slow in JavaScript is wrong far more often than it is right, and time spent optimising the wrong thing is time spent making your code harder to read in exchange for nothing at all.

Three tools cover almost everything you will need. performance.now() returns a high-resolution timestamp, so subtracting two of them times a section of code precisely. Use it rather than Date.now(), which is a wall clock and can jump backwards when the system time is corrected. console.time(label) and console.timeEnd(label) are the same idea with less typing.

The Performance panel of the developer tools is what you reach for when you do not yet know where to look. Start a recording, reproduce the slowness, stop it, and the flame chart shows exactly which functions consumed the time. That answer is very often not the one you would have guessed, which is precisely why the panel exists.

When you measure, measure the same thing several times and discard the first run — the engine has not optimised the code yet on that pass, and a single reading on a warm laptop tells you very little. Remember too that your users' devices are slower than yours; the CPU throttling option in the developer tools exists so you can experience your page the way a mid-range phone does.

Example
// Precise timing of a section
const t0 = performance.now();
const squares = [];
for (let i = 0; i < 100000; i++) {
  squares.push(i * i);
}
console.log(`took ${(performance.now() - t0).toFixed(2)} ms`);

// The same idea with less typing
console.time('squares');
const again = Array.from({ length: 100000 }, (_, i) => i * i);
console.timeEnd('squares');

// Comparing two approaches properly: warm up, repeat, average
function measure(label, fn, runs = 5) {
  fn();                                   // warm-up run, not counted
  const start = performance.now();
  for (let i = 0; i < runs; i++) fn();
  const each = (performance.now() - start) / runs;
  console.log(label, each.toFixed(3), 'ms');
}

measure('push loop', () => {
  const out = [];
  for (let i = 0; i < 10000; i++) out.push(i * i);
});
Notes
  • A measurement without a target is not much use either. Decide what "fast enough" means before you start — a list that renders in under 100 milliseconds, a search box that responds within one frame — so you know when to stop.

The DOM Is the Expensive Part

In browser code, the slow operations are nearly always the ones that touch the page. JavaScript arithmetic is extremely fast; changing the DOM can force the browser to recalculate layout and repaint, and doing that repeatedly inside a loop is what makes an interface stutter on exactly the devices you did not test on.

The first fix is to batch. Build everything inside a DocumentFragment and insert it once, as Lesson 16 showed, or build a single HTML string and assign it once. One insertion instead of two hundred is usually the entire optimisation, and it makes the code shorter rather than longer.

The second is to avoid layout thrashing, which means alternating reads and writes so the browser has to recalculate between each pair. Reading offsetHeight, getBoundingClientRect() or scrollTop forces the browser to settle any pending changes before it can answer. Interleave those reads with writes inside a loop and you force a full layout calculation on every single iteration. Do all the reading first, then all the writing, and the same work costs a fraction as much.

The third is to change a class rather than several individual styles. One class change is one style recalculation, where five separate assignments to element.style can be more — and the class keeps the design in the stylesheet where it belongs. Where you are animating, prefer transform and opacity, which browsers can generally handle without recalculating layout at all.

Example
const list = document.querySelector('#tasks');
const items = [...document.querySelectorAll('.item')];

// Slow: one insertion into the live page per item
for (let i = 0; i < 200; i++) {
  const li = document.createElement('li');
  li.textContent = 'Row ' + i;
  list.append(li);
}

// Fast: build off-page, then insert once
const fragment = document.createDocumentFragment();
for (let i = 0; i < 200; i++) {
  const li = document.createElement('li');
  li.textContent = 'Row ' + i;
  fragment.append(li);
}
list.append(fragment);

// Layout thrashing: read, write, read, write ...
items.forEach(el => {
  el.style.height = el.offsetHeight + 10 + 'px';   // forces layout each pass
});

// Batched: read everything first, then write everything
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => {
  el.style.height = heights[i] + 10 + 'px';
});

// One class change instead of several style assignments
items.forEach(el => el.classList.add('highlighted'));
Notes
  • The properties that force layout are the ones that report a measured position or size — offsetTop, offsetHeight, clientWidth, scrollTop, getBoundingClientRect(). Reading any of them inside a loop that also writes is the pattern to look for.

Events That Fire Constantly

scroll, resize, mousemove and input can fire dozens of times a second, and a handler that touches the page on every one of them is the most common single cause of a page that feels sticky.

Debounce and throttle, from Lesson 18, are the standard answers: debounce a search box so the work happens once when typing stops, throttle a scroll handler so it runs a few times a second rather than sixty. Add { passive: true } to any scroll or touch handler that never calls preventDefault, so the browser can begin scrolling immediately instead of waiting to find out whether you will cancel it.

For work that genuinely must happen every frame — an animation, a drag — use requestAnimationFrame rather than setInterval. It runs your callback immediately before the browser paints, so your changes land at exactly the right moment, and it pauses entirely when the tab is hidden instead of burning battery in the background on a phone in somebody's pocket.

Two browser APIs replace scroll handlers outright and are considerably cheaper. IntersectionObserver tells you when an element enters or leaves the viewport, which is what lazy loading, infinite scroll and "animate when visible" all actually want. ResizeObserver does the same for an element's size. Both let the browser do the watching, so prefer them to measuring positions yourself on every scroll event.

Example
function throttle(fn, wait) {
  let ready = true;
  return function (...args) {
    if (!ready) return;
    ready = false;
    fn.apply(this, args);
    setTimeout(() => { ready = true; }, wait);
  };
}

// Throttled, and marked passive because it never cancels the default
window.addEventListener('scroll', throttle(() => {
  document.body.classList.toggle('scrolled', window.scrollY > 100);
}, 100), { passive: true });

// Per-frame work belongs in requestAnimationFrame
let x = 0;
function step() {
  x = x + 1;
  document.body.style.setProperty('--x', x + 'px');
  if (x < 100) requestAnimationFrame(step);
}
requestAnimationFrame(step);

// IntersectionObserver instead of a scroll handler
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;
    entry.target.classList.add('visible');
    observer.unobserve(entry.target);     // stop watching once it has appeared
  });
});

document.querySelectorAll('.lazy').forEach(el => observer.observe(el));
Notes
  • observer.unobserve(entry.target) matters. Without it the callback keeps firing every time the element scrolls in and out of view, which turns an optimisation back into the problem it was meant to solve.

Loading Less, and Loading It Later

The fastest code is the code you never sent. For most real pages the biggest wins are about what the browser downloads and when, rather than about the JavaScript itself — a page that ships 300 kilobytes it does not need loses more time than any loop you could tighten.

Use defer on your script tags, or type="module", which defers automatically. Both let the browser parse the HTML and download your script at the same time, then run the script once the page is built. That removes the render-blocking pause a plain script in the head causes, and it also fixes the timing problem from Lesson 15 at the same time.

Split code that is not needed immediately. A dynamic import('./chart.js') downloads only when that line actually runs, so a dashboard's charting library is never downloaded by a user who does not open the chart. The same principle applies to images: loading="lazy" on an image tag defers loading until it is near the viewport, and it is a single attribute with no JavaScript at all.

Then stop repeating work. Caching the result of an expensive pure function in a Map keyed by its input is called memoisation, and it is about eight lines. Caching network responses that rarely change is the same idea one level up. Both must be bounded, though: a cache that grows forever is a memory leak with a friendly name, and the last section of this lesson comes back to that.

Example
// Load a heavy module only when it is actually needed
document.querySelector('#showChart')?.addEventListener('click', async () => {
  const { renderChart } = await import('./chart.js');
  renderChart();
});

// Memoise an expensive pure function
function memoise(fn) {
  const cache = new Map();
  return function (arg) {
    if (cache.has(arg)) return cache.get(arg);
    const result = fn(arg);
    cache.set(arg, result);
    return result;
  };
}

function slowSquare(n) {
  let waste = 0;
  for (let i = 0; i < 1000000; i++) waste += 1;   // pretend this is real work
  return n * n;
}

const fastSquare = memoise(slowSquare);

console.time('first call');
console.log(fastSquare(12));
console.timeEnd('first call');

console.time('second call');
console.log(fastSquare(12));    // straight out of the cache
console.timeEnd('second call');
Notes
  • Memoisation is only safe for pure functions — same input, same output, no side effects. Caching the result of a function that reads the page or the clock means serving a stale answer forever, which is a far worse bug than being slow.

Algorithmic Cost

Sometimes the code really is the problem, and when it is, the cause is usually the shape of the algorithm rather than the syntax you chose to write it in.

The classic case is a nested loop, and its most common disguise is a find inside a map. Matching two thousand students against two thousand marks records that way performs four million comparisons, and each one is cheap only in isolation. Building a Map from one list first and then looking values up turns that into four thousand operations — the same result, in a fraction of the time, and the code arguably reads better than it did before.

Map and Set are the tools for this. A Set answers "have I seen this before?" in effectively constant time, where array.includes scans from the beginning every time. A Map answers "what is the record for this id?" in constant time, where array.find scans. On a list of ten items the difference is invisible; the difference appears exactly at the size where it starts to matter.

And hoist work out of loops. Constructing a formatter, compiling a regular expression, or querying the page inside a loop body repeats that cost on every pass for no benefit whatsoever. Move anything that does not depend on the loop variable above the loop — the same advice as Lesson 9, and it is here again because it is the cheapest performance fix there is.

Example
const students = Array.from({ length: 2000 }, (_, i) => ({ id: i, name: 'S' + i }));
const marks = Array.from({ length: 2000 }, (_, i) => ({ studentId: i, score: i % 100 }));

// Slow: a find inside a map is a nested loop wearing a disguise
console.time('nested');
const slow = students.map(s => ({
  name: s.name,
  score: marks.find(m => m.studentId === s.id).score
}));
console.timeEnd('nested');

// Fast: index once, then look up
console.time('indexed');
const byStudent = new Map(marks.map(m => [m.studentId, m]));
const fast = students.map(s => ({
  name: s.name,
  score: byStudent.get(s.id).score
}));
console.timeEnd('indexed');

console.log(slow[1999].score === fast[1999].score);   // true - same answer

// A Set for membership tests
const banned = new Set(['spam', 'test', 'demo']);
console.log(banned.has('test'));   // constant time, not a scan
Notes
  • Run those two timings yourself. The gap between them is the difference between an algorithm whose cost grows with the square of the input and one that grows in step with it — and no amount of micro-optimising the slow version would close it.

Memory Leaks, and When Not to Optimise

A memory leak in JavaScript is something still being referenced that should not be. The garbage collector frees anything unreachable automatically, so a leak always means some reference is still reachable from somewhere — and finding it is a matter of asking what is still holding on.

Four causes account for most leaks in browser code. A listener added to window or document and never removed, which keeps its entire closure alive along with everything that closure can see. A setInterval that is never cleared. A cache, array or Map that grows and is never trimmed. And a variable still holding a reference to a DOM element you removed from the page, which keeps that element and all its children in memory even though nobody can see them.

One habit prevents all four: pair every setup with a teardown. Every addEventListener on something long-lived has a matching removeEventListener, or uses { once: true }, or an AbortController. Every setInterval has a clearInterval. Every cache has a maximum size. Writing the teardown at the same moment as the setup is far easier than remembering to add it three weeks later.

Finally, the discipline that matters more than any technique here. Optimise when you have a measurement showing a problem a user would actually notice — not when you have a suspicion, and not because a snippet online said something was slow. Most pages are fast enough, and clarity is worth considerably more than a millisecond nobody can perceive. When you do optimise, record what the clever version replaced, in a comment or a commit message, so the next person knows why the obvious code is not there.

Example
// A leak: setup with no teardown
// const timer = setInterval(() => console.log('tick'), 1000);

// Fixed: hand back the way to stop it
function startTicking() {
  const id = setInterval(() => console.log('tick'), 1000);
  return function stop() {
    clearInterval(id);
  };
}
const stop = startTicking();
stop();

// Listeners on long-lived objects need a defined end
function onResize() {
  console.log('resized');
}
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
controller.abort();      // removed, and the closure can now be collected

// A bounded cache, rather than one that grows forever
const MAX_ENTRIES = 100;
const cache = new Map();

function remember(key, value) {
  if (cache.size >= MAX_ENTRIES) {
    cache.delete(cache.keys().next().value);   // drop the oldest entry
  }
  cache.set(key, value);
}

remember('a', 1);
console.log(cache.size);   // 1
  • Measure with performance.now() or the Performance panel before changing anything
  • Touching the DOM is the expensive part, not the arithmetic
  • Batch insertions through a DocumentFragment and insert once
  • Read all layout values first, then write — never alternate inside a loop
  • Debounce input, throttle scroll, and mark read-only handlers passive
  • requestAnimationFrame for per-frame work, never setInterval
  • IntersectionObserver instead of measuring positions on every scroll
  • defer your scripts; use dynamic import() for what is not needed yet
  • Map and Set instead of a find or includes inside a loop
  • Pair every listener, interval and cache with a defined way to clean it up
Notes
  • The Memory panel of the developer tools takes heap snapshots. Take one, use the feature you suspect, take another, and compare: whatever grew and was never released is your leak, and the panel will show you what is still holding a reference to it.
Ask AI