JavaScriptPerformance Optimization

Performance Optimization

Most performance work in JavaScript is not about micro-optimising operators or shaving nanoseconds off a loop. It is about doing less work, doing it at the right time, and not blocking the main thread. This page collects the patterns that pay back in real applications, in roughly the order you should reach for them.

Measure first, optimise second

Before you change anything, measure. Otherwise you will trade a 10ms saving in code that runs once for a regression in code that runs every frame. The cheapest tools:

console.time

JS
console.time("render");
renderEverything();
console.timeEnd("render");   // "render: 84.2ms"

performance.now() — finer grained

JS
const t0 = performance.now();
doWork();
const t1 = performance.now();
console.log(`doWork took ${(t1 - t0).toFixed(2)}ms`);

User Timing marks show up in the Performance tab

JS
performance.mark("parse:start");
parse(input);
performance.mark("parse:end");
performance.measure("parse", "parse:start", "parse:end");
Profile with realistic data
A function that is fast on 100 rows can be quadratic and unusable on 10,000. Always profile with input the size your users actually see.
Don't block the main thread

JavaScript in the browser runs on the same thread that paints the page and handles input. If a task takes longer than about 16 ms you start dropping frames; longer than ~50 ms and clicks feel laggy. The fix is almost never "make this loop tighter" — it is to break the work up.

  • Slice it with requestIdleCallback, queueMicrotask, or a chunked setTimeout(..., 0) loop.

  • Move it off the main thread entirely with a Web Worker — see Web Workers.

  • Skip it when possible by short-circuiting (cache, memoize, early-return).

chunking a long loop

JS
function processChunked(items, onDone) {
  let i = 0;
  function step() {
    const end = Math.min(i + 500, items.length);
    while (i < end) handle(items[i++]);
    if (i < items.length) setTimeout(step, 0);  // yield to the browser
    else onDone();
  }
  step();
}
Debounce and throttle

Events like input, scroll, resize and mousemove fire many times per second. Most of the time you only need the last value or a sampled value.

debounce — run only after a quiet period

JS
function debounce(fn, wait = 200) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

input.addEventListener("input", debounce((e) => search(e.target.value), 250));

throttle — at most once per interval

JS
function throttle(fn, wait = 100) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}

window.addEventListener("scroll", throttle(updateScrollIndicator, 100));

Use debounce for "wait until they stopped typing" and throttle for "update at most every 100ms while they scroll".

Batch DOM reads and writes

Reading layout (offsetWidth, getBoundingClientRect, scrollTop) forces the browser to make sure layout is up-to-date. Writing to the DOM invalidates layout. Interleaving them causes layout thrashing — the browser recomputes layout many times for one frame of work.

bad — read/write/read/write

JS
for (const el of items) {
  el.style.width = el.offsetWidth + 10 + "px";   // read THEN write THEN read THEN write...
}

better — read all, then write all

JS
const widths = items.map((el) => el.offsetWidth);  // batch of reads
items.forEach((el, i) => {
  el.style.width = widths[i] + 10 + "px";          // batch of writes
});

For complex updates, use requestAnimationFrame to schedule writes for the next frame and let the browser combine them.

Move compute off the main thread

Image processing, parsing large JSON, search indexes, audio analysis — all classic Web Worker work. The main thread sends a message; the worker computes; the main thread receives the result and updates the UI.

main.js

JS
const worker = new Worker(new URL("./crunch.js", import.meta.url), { type: "module" });
worker.postMessage(bigDataset);
worker.onmessage = (e) => render(e.data);

crunch.js

JS
self.onmessage = (e) => {
  const result = heavyAlgorithm(e.data);
  self.postMessage(result);
};

Use Transferable objects (typed arrays, ArrayBuffer) to avoid copying large payloads across the boundary.

Cache and pre-warm

If a function is pure and called often with the same inputs, memoize it. If a request will be needed in a moment, prefetch it. If the next page's bundle can be guessed, <link rel="prefetch"> it.

tiny memoizer

JS
function memo(fn) {
  const cache = new Map();
  return function (key) {
    if (!cache.has(key)) cache.set(key, fn(key));
    return cache.get(key);
  };
}

const slugify = memo((s) => s.toLowerCase().replace(/\s+/g, "-"));

Pre-warming is "do the expensive thing while the user is idle so it is hot when they arrive". Pair it with requestIdleCallback or low-priority fetch (fetch(url, { priority: "low" })).

Smaller, lazier bundles
  • Code-split routes and dialogs so the initial bundle stays under 100 KB gzipped where possible.

  • Use dynamic import() for heavy features (charts, editors, PDF) that only some users need.

  • Tree-shake — prefer named ESM imports over import * as Lib so bundlers can drop unused code.

  • Compress with Brotli on the wire; the browser handles it transparently.

Smaller wins worth knowing
  • Avoid allocating in a hot loop. Reuse objects, pass arrays in, mutate outputs you own.

  • Prefer for / for..of over forEach only when you have measured a difference — usually irrelevant.

  • Use Map for frequently mutated keyed data; use plain objects for small, static shapes.

  • Replace string concatenation in loops with Array.join or template parts when you measure a cost — modern engines are good at strings, so check first.

  • Pre-size arrays you know the length of: new Array(n) then assign by index.

Beware folklore
Old "tips" — `for (var i = 0; i < arr.length; i++)` is faster, `delete` is slow, `undefined` is faster than `void 0` — were measured on engines that no longer exist. Always re-measure before propagating advice from a 2014 blog post.
A working order of operations
1) Measure. 2) Reduce work (cache, batch, debounce). 3) Defer work (idle, raf, workers). 4) Trim bundles. 5) Only then start sweating individual statements.