Skip to main content
JavaScript · lesson 22 of 24
JavaScript advanced Lesson 22 of 24

JavaScript Performance Optimization

Practical JavaScript performance techniques — debounce, throttle, lazy loading, avoiding layout thrash, V8 optimization, and Web Workers.

By SkillByExample · Updated

Performance work starts with a measurement, not a hunch. Each technique below is shown with a program that measures its effect — timings from Node 22, and, for the one browser-only problem (layout thrash), from headless Chromium. Your numbers will differ; the ratios are the point.

Debounce

Debouncing waits for a burst of calls to stop, then runs once — the fix for “search on every keystroke”.

function debounce(fn, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(this, args), delay);
  };
}

const start = performance.now();
const at = () => `${Math.round((performance.now() - start) / 50) * 50}ms`;
const search = debounce((q) => console.log(`${at()}  search("${q}")`), 300);

const typed = "coffee";
typed.split("").forEach((_, i) => {
  setTimeout(() => search(typed.slice(0, i + 1)), i * 100);
});
setTimeout(() => search("coffee shop"), 1000);
$ node debounce.js
800ms  search("coffee")
1300ms  search("coffee shop")

Seven calls, two searches. Each keystroke 100 ms apart cancelled the previous timer, so only "coffee" ran — 300 ms after the last key (500 + 300). The second burst ran on its own. Choose the delay by feel: long enough to span a typing pause, short enough that results don’t seem laggy.

Throttle

Throttling runs at most once per interval during a burst — right for scroll and resize, where you want steady updates, just not hundreds per second.

function throttle(fn, interval) {
  let last = 0;
  let trailing;
  return function (...args) {
    const remaining = interval - (Date.now() - last);
    clearTimeout(trailing);
    if (remaining <= 0) {
      last = Date.now();
      fn.apply(this, args);
    } else {
      trailing = setTimeout(() => { last = Date.now(); fn.apply(this, args); }, remaining);
    }
  };
}

let events = 0;
let calls = 0;
const onScroll = throttle((y) => { calls += 1; console.log(`update at scrollY=${y}`); }, 100);

const timer = setInterval(() => {
  events += 1;
  onScroll(events * 10);
  if (events === 45) {
    clearInterval(timer);
    setTimeout(() => console.log(`${events} scroll events -> ${calls} updates`), 150);
  }
}, 10);
$ node throttle.js
update at scrollY=10
update at scrollY=100
update at scrollY=200
update at scrollY=300
update at scrollY=400
update at scrollY=450
45 scroll events -> 6 updates

About 450 ms of scrolling at one event per ~10 ms produced a handful of updates, roughly one per 100 ms — and the final position (scrollY=450) still arrived, thanks to the trailing call. Without that trailing call, a throttle can leave the UI showing a position from mid-scroll. (Timer granularity makes the exact positions vary slightly between runs.)

Layout Thrash (Browser)

In a browser, reading a layout value such as offsetWidth right after a style write forces a synchronous layout. Alternating reads and writes in a loop forces one per iteration. This example drives headless Chromium with Playwright (npm install playwright, then npx playwright install chromium); everything inside page.evaluate runs in the page.

import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();

const result = await page.evaluate(() => {
  for (let i = 0; i < 500; i++) document.body.insertAdjacentHTML("beforeend", `<div style="width:100px">item ${i}</div>`);
  const els = [...document.body.children];

  const interleaved = () => els.forEach((el) => { el.style.width = el.offsetWidth + 1 + "px"; });
  const batched = () => {
    const widths = els.map((el) => el.offsetWidth);
    els.forEach((el, i) => { el.style.width = widths[i] + 1 + "px"; });
  };
  const time = (fn) => {
    const t = performance.now();
    fn();
    document.body.offsetWidth;
    return `${(performance.now() - t).toFixed(1)} ms`;
  };
  return { interleaved: time(interleaved), batched: time(batched) };
});

console.log(result);
await browser.close();
$ node thrash.mjs
{ interleaved: '90.3 ms', batched: '3.0 ms' }

Same work, same result — but the interleaved loop made Chromium recompute layout 500 times, because every offsetWidth read had to account for the previous write. The batched version reads everything first (one layout), then writes (one more layout at the end). In real code: group reads before writes, and put visual updates in requestAnimationFrame.

Hidden Classes: Keep Object Shapes Consistent

V8 gives every object a hidden “shape” (a map) based on which properties it has and in what order. Code that always sees the same shape can be heavily optimised. The % functions below are V8 internals, enabled by --allow-natives-syntax purely to look inside — never use them in real code.

const a = { x: 1, y: 2 };
const b = { x: 5, y: 9 };
const c = { y: 2, x: 1 };
const d = { x: 1, y: 2, z: 3 };
delete d.z;

console.log("a,b same shape:", %HaveSameMap(a, b));
console.log("a,c same shape:", %HaveSameMap(a, c));
console.log("a,d same shape:", %HaveSameMap(a, d));
console.log("d fast properties after delete:", %HasFastProperties(d));
$ node --allow-natives-syntax shapes.js
a,b same shape: true
a,c same shape: false
a,d same shape: false
d fast properties after delete: false

a and b share a shape even with different values. c has the same properties in a different order — a different shape. delete is worse than it looks: d has the same keys as a again, but deleting a property pushed it into slow “dictionary mode”. To clear a value, assign undefined; to omit a key, build a new object with rest syntax.

Here is what shape consistency is worth in a hot loop:

function sumXY(objs) {
  let total = 0;
  for (let r = 0; r < 200; r++) for (const o of objs) total += o.x + o.y;
  return total;
}
function bench(label, objs) {
  sumXY(objs);
  const t = performance.now();
  sumXY(objs);
  console.log(label.padEnd(12), (performance.now() - t).toFixed(1), "ms");
}

const N = 10_000;
const sameShape = Array.from({ length: N }, (_, i) => ({ x: i, y: i }));
const mixedShapes = Array.from({ length: N }, (_, i) => ({ x: i, y: i, ["k" + (i % 8)]: 0 }));

bench("1 shape", sameShape);
bench("8 shapes", mixedShapes);
$ node mono.js
1 shape      2.9 ms
8 shapes     18.3 ms

Both loops read the same x and y two million times. With one shape, the property access is monomorphic — V8 caches exactly where x and y live. With eight shapes it becomes megamorphic, a generic lookup that is several times slower. The practical rule: create objects of one kind in one place (a constructor, class or factory) with every property initialised, even to null.

Memory Leaks

Garbage collection frees what nothing references. Leaks are references you forgot you were holding — in caches, closures, listeners and timers.

const mb = () => (process.memoryUsage().heapUsed / 1e6).toFixed(0) + " MB";
const strongCache = new Map();
const weakCache = new WeakMap();

function handleRequest(cache) {
  const request = { id: Math.random(), body: new Array(10_000).fill("x") };
  cache.set(request, request.body.length);
}

console.log("start:          ", mb());
for (let i = 0; i < 2000; i++) handleRequest(strongCache);
globalThis.gc();
console.log("after Map:      ", mb(), `(${strongCache.size} entries kept)`);

strongCache.clear();
globalThis.gc();
for (let i = 0; i < 2000; i++) handleRequest(weakCache);
globalThis.gc();
console.log("after WeakMap:  ", mb());
$ node --expose-gc leak.js
start:           4 MB
after Map:       164 MB (2000 entries kept)
after WeakMap:   4 MB

Each request object is gone once the function returns — except that a Map key is a strong reference, so the Map kept all 2,000 requests (and their arrays) alive. A WeakMap holds keys weakly: once nothing else references a request, its entry disappears with it. Use a WeakMap whenever you attach data to objects you don’t own, such as DOM elements. --expose-gc just lets the example force a collection for stable numbers.

Timers and listeners leak the same way — a closure keeps what it references for as long as the timer or listener exists:

function startPolling(onData) {
  const bigState = new Array(1_000_000).fill(0);
  let ticks = 0;
  const id = setInterval(() => onData(++ticks, bigState.length), 20);
  return () => clearInterval(id);
}

const stop = startPolling((n, size) => {
  console.log(`poll ${n} (holding ${size} items)`);
  if (n === 3) {                                    // stop on a real condition, not a deadline
    stop();
    console.log("stopped: the interval, its closure and bigState can now be collected");
  }
});
$ node timers.js
poll 1 (holding 1000000 items)
poll 2 (holding 1000000 items)
poll 3 (holding 1000000 items)
stopped: the interval, its closure and bigState can now be collected

Returning a cleanup function and calling it on teardown is the pattern React effects, Vue’s onUnmounted and AbortController all formalise. Without stop(), this process would never exit — a live interval keeps both the event loop and bigState alive forever.

Moving CPU Work Off the Main Thread

A long computation blocks the thread that also handles events (or, in a browser, rendering). A worker runs it in parallel. This uses Node’s worker_threads; browser Web Workers use the same postMessage model.

import { Worker } from "node:worker_threads";

const workerCode = `
  const { parentPort } = require("node:worker_threads");
  parentPort.on("message", (n) => {
    let count = 0;
    for (let i = 2; i < n; i++) { let prime = true; for (let j = 2; j * j <= i; j++) if (i % j === 0) { prime = false; break; } if (prime) count++; }
    parentPort.postMessage(count);
  });`;

let heartbeats = 0;
const heartbeat = setInterval(() => heartbeats++, 10);
const start = performance.now();

const worker = new Worker(workerCode, { eval: true });
worker.postMessage(3_000_000);
const primes = await new Promise((resolve) => worker.once("message", resolve));
console.log(`${primes} primes below 3,000,000 in ${Math.round(performance.now() - start)} ms`);
console.log(`main thread heartbeats meanwhile: ${heartbeats}`);

const buffer = new ArrayBuffer(8_000_000);
worker.postMessage(buffer, [buffer]);
console.log("buffer.byteLength after transfer:", buffer.byteLength);
clearInterval(heartbeat);
await worker.terminate();
$ node worker.mjs
216816 primes below 3,000,000 in 847 ms
main thread heartbeats meanwhile: 81
buffer.byteLength after transfer: 0

The main thread’s 10 ms heartbeat kept firing throughout the computation — it was never blocked. Run the same loop on the main thread and the heartbeat count would be zero. The last lines show a transfer: listing the ArrayBuffer in postMessage’s second argument moves it to the worker without copying 8 MB, and the sender’s copy becomes empty (byteLength 0).

Quick Reference

ProblemTool
Rapid-fire input eventsDebounce
Scroll / resize handlersThrottle, with { passive: true } listeners
Large bundle on startupDynamic import() — see Modules
DOM read/write orderingBatch reads, then writes; requestAnimationFrame
Hot loops over objectsConsistent shapes; no delete
Data attached to objects you don’t ownWeakMap
CPU-heavy computationWeb Worker / worker_threads

Practice

1. Add a cancel() method to debounce so a pending call can be dropped.
function debounce(fn, delay) {
  let timerId;
  const debounced = (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  };
  debounced.cancel = () => clearTimeout(timerId);
  return debounced;
}

const save = debounce((v) => console.log("saved", v), 50);
save("draft 1");
save("draft 2");
save.cancel();
save("draft 3");
$ node p1.js
saved draft 3

Only draft 3 is saved: cancel dropped the pending draft 2. Useful when a component unmounts before the delay expires.

2. Show that a shape-stable factory produces objects with the same hidden class even when optional fields are missing.
const makeUser = ({ name, email = null, age = null }) => ({ name, email, age });
const u1 = makeUser({ name: "Alice", email: "a@example.com" });
const u2 = makeUser({ name: "Bob", age: 40 });
const u3 = { name: "Carol" };
console.log(%HaveSameMap(u1, u2), %HaveSameMap(u1, u3));
$ node --allow-natives-syntax p2.js
true false

Defaulting missing fields to null keeps every user the same shape; the hand-built u3 isn’t.

3. Measure how long a 200 ms busy loop blocks a 10 ms interval on the main thread.
let ticks = 0;
const id = setInterval(() => ticks++, 10);
setTimeout(() => {
  const end = Date.now() + 200;
  while (Date.now() < end) {}
  console.log("ticks during the busy loop:", ticks);
  setTimeout(() => { console.log("ticks 50 ms later:", ticks); clearInterval(id); }, 50);
}, 0);
$ node p3.js
ticks during the busy loop: 0
ticks 50 ms later: 5

Zero ticks: while the loop runs, no callback can. Once it ends, the interval resumes — it doesn’t “catch up” on the 20 ticks it missed.

4. Replace a delete with rest syntax to produce an object without a key, and check its shape.
const withSecret = { id: 1, name: "Alice", password: "hunter2" };
const { password, ...safe } = withSecret;
const { password: pw2, ...safe2 } = { id: 2, name: "Bob", password: "x" };
const literal = { id: 3, name: "Carol" };
console.log(safe, %HasFastProperties(safe));
console.log("rest vs rest:", %HaveSameMap(safe, safe2), "| rest vs literal:", %HaveSameMap(safe, literal));
$ node --allow-natives-syntax p4.js
{ id: 1, name: 'Alice' } true
rest vs rest: true | rest vs literal: false

Rest syntax builds a fresh object in the ordinary fast mode — no dictionary-mode penalty. Note the last result, though: a rest copy and a literal with the same two keys got different hidden classes, because V8 builds them through different internal paths. Consistency means one code path making all objects of a kind, not merely the same keys.

Frequently Asked Questions

What is the difference between debounce and throttle?
Debounce delays execution until a burst of calls stops — it resets a timer on each call and only fires after the quiet period ends. Throttle limits execution to once per interval — it fires immediately on the first call, and calls during the interval collapse into one trailing call when it ends, so the final state is never lost. Use debounce for search-as-you-type (fire after typing stops). Use throttle for scroll and resize handlers (fire at most every 100ms).
What causes layout thrash?
Layout thrash (forced synchronous layout) happens when you read a layout property (offsetWidth, getBoundingClientRect) immediately after writing a style, forcing the browser to recalculate layout synchronously. Batch all reads first, then all writes. Use requestAnimationFrame to separate reads and writes across frames.
How do I find memory leaks in a JavaScript application?
Open Chrome DevTools → Memory tab → take a heap snapshot, do an action, take another snapshot, compare. Look for objects that keep growing. Common culprits: event listeners not removed, closures holding DOM nodes, global variables, timers not cleared, WeakRef not used for caches.