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
console.time("render");
renderEverything();
console.timeEnd("render"); // "render: 84.2ms"performance.now() — finer grained
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
performance.mark("parse:start");
parse(input);
performance.mark("parse:end");
performance.measure("parse", "parse:start", "parse:end");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 chunkedsetTimeout(..., 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
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
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
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
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
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
const worker = new Worker(new URL("./crunch.js", import.meta.url), { type: "module" });
worker.postMessage(bigDataset);
worker.onmessage = (e) => render(e.data);crunch.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
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 Libso 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..ofoverforEachonly when you have measured a difference — usually irrelevant.Use
Mapfor frequently mutated keyed data; use plain objects for small, static shapes.Replace string concatenation in loops with
Array.joinor 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.