Layout Thrashing & Reflow
advancedWhy interleaving DOM reads and writes forces synchronous layout, the read/write batching fix, requestAnimationFrame, and compositor-only properties.
A hands-on JavaScript optimization exercise: take a realistic slow function and speed it up in stages — profile first, fix the algorithm, hoist invariant work, cut allocations, memoize, and chunk off the main thread.
Here's a function that builds a report. On big inputs it freezes the tab for seconds. Make it fast — and narrate what you do and why.
This is the open-ended optimization exercise senior interviews use to watch you think. There's no single trick; there's a disciplined order. Do the steps in the wrong order — micro-tuning before fixing the algorithm — and you'll polish code that was never the bottleneck.
The one-liner: profile → fix the algorithm → cut constants → get off the main thread. In that order, measuring between each.
Here's the starting function. It takes a list of orders and a list of "active" customer ids, and returns each active customer's total spend plus a formatted label:
function buildReport(orders, activeIds) {
const report = [];
for (const id of activeIds) {
// recompute the customer's orders from scratch, every id
const mine = orders.filter((o) => o.customerId === id);
let total = 0;
for (const o of mine) {
total += o.amount * currencyRate(o.currency); // pure, but called a lot
}
report.push({
id,
total,
label: `${activeIds.length} active · ${new Date().getFullYear()} report`,
});
}
return report;
}Before touching anything, get a baseline and find the hot path with the profiler:
performance.mark("report-start");
buildReport(orders, activeIds);
performance.mark("report-end");
performance.measure("buildReport", "report-start", "report-end");The flame chart will show almost all the time inside that orders.filter(...) line. That points straight at the algorithm — which is where the biggest win almost always is.
For each of the A active ids, we scan all N orders with filter. That's O(A·N) — the accidental quadratic. Do one pass to group orders by customer into a Map (O(N)), then each lookup is O(1):
function buildReport(orders, activeIds) {
const byCustomer = new Map(); // one O(N) pass instead of A scans
for (const o of orders) {
const list = byCustomer.get(o.customerId);
if (list) list.push(o);
else byCustomer.set(o.customerId, [o]);
}
const report = [];
for (const id of activeIds) {
const mine = byCustomer.get(id) ?? []; // O(1) lookup
let total = 0;
for (const o of mine) total += o.amount * currencyRate(o.currency);
report.push({ id, total, label: `${activeIds.length} active · ${new Date().getFullYear()} report` });
}
return report;
}O(A·N) → O(N + A). On 10k orders and 1k active ids that's ~10,000,000 comparisons down to ~11,000. Everything after this is polish by comparison — which is why you do it first.
new Date().getFullYear() and activeIds.length are recomputed on every iteration but never change. Date construction in particular isn't free. Compute loop invariants once:
const year = new Date().getFullYear();
const label = `${activeIds.length} active · ${year} report`; // identical for every rowSame idea for repeated property access and .length reads in hot inner loops — read once into a local. Small individually; free to do; they add up in the tightest loops.
Step 1 built a full array of orders per customer just to sum them. We never need the arrays — only the totals. Fold the sum into the grouping pass and allocate nothing per order:
function buildReport(orders, activeIds) {
const totals = new Map();
for (const o of orders) {
const add = o.amount * currencyRate(o.currency);
totals.set(o.customerId, (totals.get(o.customerId) ?? 0) + add); // number, not array
}
const year = new Date().getFullYear();
const label = `${activeIds.length} active · ${year} report`;
return activeIds.map((id) => ({ id, total: totals.get(id) ?? 0, label }));
}Fewer allocations means less for the garbage collector to sweep — fewer GC pauses, less jank. (Beware the accidental-quadratic allocation: never rebuild a growing array with spread inside a loop.)
currencyRate(currency) is pure and called once per order, but there are only a handful of currencies — so it recomputes the same few answers thousands of times. Cache it:
import { memoize } from "./memoize"; // see /javascript/custom-memoize-function
const rate = memoize(currencyRate); // one lookup per distinct currency, then O(1)Memoization is the space-for-time trade again — worth it only when the function is pure, the call is hot, and the key space is small. If keys are unbounded, bound the cache with an LRU so the optimization doesn't become a leak.
// per row, still running:
new Date().getFullYear() // invariant
currencyRate(o.currency) // pure, ~5 distinct inputs, called per order
report.push({ ...spread }) // per rowSuppose even the O(N) version is genuinely huge and blocks the UI. The fix isn't a faster algorithm — it's not blocking:
await scheduler.yield() (or a setTimeout(…, 0) / MessageChannel fallback) breaks one 500 ms long task into many short ones, keeping the page responsive.async function buildReportChunked(orders, activeIds, chunk = 5000) {
const totals = new Map();
for (let i = 0; i < orders.length; i += chunk) {
for (const o of orders.slice(i, i + chunk)) {
totals.set(o.customerId, (totals.get(o.customerId) ?? 0) + o.amount * rate(o.currency));
}
await scheduler.yield?.() ?? new Promise((r) => setTimeout(r, 0)); // let the UI breathe
}
const label = `${activeIds.length} active · ${new Date().getFullYear()} report`;
return activeIds.map((id) => ({ id, total: totals.get(id) ?? 0, label }));
}Chunking doesn't reduce total work — it keeps the frame budget intact so the app doesn't freeze. That's often what "make it faster" actually means to a user: responsive, not merely quicker.
postMessage. Chunking when the work must stay on the main thread but can yield.Why interleaving DOM reads and writes forces synchronous layout, the read/write batching fix, requestAnimationFrame, and compositor-only properties.
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.