Optimize a Slow Function
intermediateA hands-on exercise: profile first, fix the algorithm, hoist invariant work, cut allocations, memoize, then chunk off the main thread — in that order.
Why interleaving DOM reads and writes forces synchronous layout and tanks performance — the rendering pipeline, forced reflow, the read/write batching fix, requestAnimationFrame, and compositor-only properties.
This loop resizes 500 list items to match their content and it janks badly. Why is it slow, and how do you fix it without changing what it does?
for (const item of items) {
// read, then write, then read, then write...
item.style.height = item.offsetHeight + 20 + "px";
}This is layout thrashing, and it's one of the most common real-world frontend performance questions because the fix is invisible: the code looks O(n), but it behaves far worse. Understanding why is the difference between "make it faster" and "I'd add a will-change" (a non-answer).
The one-liner: batch your reads, then batch your writes — never interleave them.
Every visual update flows through a pipeline. Each frame (ideally every ~16 ms for 60fps) the browser may run:
JavaScript → Style → Layout → Paint → Composite
The engine is smart: it batches DOM writes and recalculates layout once, right before painting. You get that batching for free… until you break it.
Reading a geometric property — offsetHeight, offsetTop, getBoundingClientRect(), getComputedStyle(), scrollTop, clientWidth — must return an up-to-date value. If there are pending writes queued, the browser cannot hand you a stale number, so it stops and runs layout right now to answer you. That's a forced synchronous layout (or "forced reflow").
In the loop above, each iteration:
item.style.height → dirties the layout,offsetHeight → forces a full synchronous reflow to flush the dirty state.So instead of one batched layout at the end, you trigger a layout on every single iteration — and because layout cost scales with the DOM, you've turned a linear loop into something closer to O(n²) in practice. That's layout thrashing: the read/write/read/write ping-pong that defeats the browser's batching.
Same behavior, one reflow instead of 500 — measure everything first, then mutate everything:
// PHASE 1 — read every measurement up front (one layout flush total)
const heights = items.map((item) => item.offsetHeight);
// PHASE 2 — now only write; nothing reads geometry, so nothing forces layout
items.forEach((item, i) => {
item.style.height = heights[i] + 20 + "px";
});Reads no longer sit after writes, so the browser never has to flush mid-loop. It coalesces all the writes and lays out once before the next paint. This read-then-write discipline is the whole idea behind the FastDOM pattern and libraries like it.
// A
boxes.forEach(b => {
const w = b.offsetWidth;
b.style.width = w * 2 + "px";
});
// B
const widths = boxes.map(b => b.offsetWidth);
boxes.forEach((b, i) => { b.style.width = widths[i] * 2 + "px"; });requestAnimationFramerequestAnimationFrame(cb) runs cb right before the next paint — the correct place to make visual changes, because they'll be picked up in that frame's single layout pass instead of forcing an extra one. Pair it with the read/write split: measure now, mutate in the rAF callback.
const measurements = elements.map((el) => el.getBoundingClientRect());
requestAnimationFrame(() => {
elements.forEach((el, i) => animateInto(el, measurements[i]));
});(This is exactly the read-then-write technique the FLIP animation pattern uses to animate layout changes smoothly.)
DocumentFragment and append it once, or set display:none on a container, do bulk edits, then reveal — a hidden element isn't laid out, so edits don't reflow.transform and opacity can be handled on the GPU without layout or paint. Animating left/top/width/margin reflows every frame; animating transform: translate() doesn't. This is the single biggest win for smooth animation.content-visibility: auto lets the browser skip layout and paint for off-screen subtrees entirely — near-free virtualization for long static pages.scroll and resize fire rapidly; reading geometry in them is thrashing on a timer. Throttle them, or use IntersectionObserver / ResizeObserver, which report geometry asynchronously without you forcing a synchronous read.offsetTop/Left/Width/Height, client*, scroll*, getBoundingClientRect(), getComputedStyle() for computed sizes, scrollTo/focus in some cases. Reading any of them flushes pending style/layout.transform faster than top?" top changes geometry → layout + paint + composite. transform can be composited on its own GPU layer → composite only, no layout or paint.will-change the fix?" It's a hint that promotes an element to its own layer ahead of time, useful for animations — but it costs memory and, overused, hurts more than it helps. It doesn't fix thrashing; batching reads/writes does.A hands-on exercise: profile first, fix the algorithm, hoist invariant work, cut allocations, memoize, then chunk off the main thread — in that order.
How GC reachability actually works, the four classic leaks, WeakMap/WeakRef, and finding retained memory with heap snapshots.
Big-O reasoning the way interviewers want it: the Map/Set/object/array cheat sheet, hidden O(n) costs like spread and includes, and trading memory for speed.
performance.now vs Date.now, the User Timing API, PerformanceObserver, DevTools flame charts, and the micro-benchmark traps that make numbers lie.