Rendering performance · interactive

Layout Thrashing Lab

Tick a box to inject a specific rendering anti-pattern into a live animation loop, then watch it show up in the frame metrics. Everything runs on your machine, calibrated to your display's real refresh rate.

1 · The browser batches layout

Changing the DOM does not recompute anything. A write just marks layout dirty — the plan is to recompute geometry once, right before the next paint, no matter how many writes your code makes. Clean layout is cached and free to read. Dirty layout is a promise to recompute later.

2 · Reads turn "later" into "right now"

Ask for a geometry value — offsetHeight, getBoundingClientRect, getComputedStyle — while layout is dirty, and the browser must reflow synchronously to answer truthfully. In a loop that is N reflows per frame instead of one. The fix never changes: batch every read, then every write. One pair on one element is a rounding error; the scale is the whole difference.

3 · Expensive frames, and a meter that can lie

transform and opacity ride the compositor. top, left, width and margin re-run layout every frame; big shadows and blur re-paint every frame — on the compositor thread, where a main-thread FPS meter cannot see the cost. Always know which thread your metric is watching.

measuring the display…

Jank toggles

  • Writes a style, then reads geometry — for every box, every frame.

    Why it's slow. The browser batches layout: a write only marks it dirty, planning one reflow before the next paint. But offsetHeight must be a truthful, up-to-date number — so the read forces that reflow right now, mid-loop. N boxes means N full reflows in one frame instead of one.

    The fix. Batch it: do all the reads first while layout is still clean, then all the writes. One reflow, not N. Tick the batched control below to watch the same work stay green.

    for (const box of boxes) {
      box.style.borderTopWidth = flicker; // WRITE → layout dirty
      read(box.offsetHeight);             // READ → forced sync reflow
    }
  • Moves every box by writing top/left each frame.

    Why it's slow. top and left are layout properties: changing them re-runs layout and paint on every frame. transform draws the same motion by moving an already-painted layer on the compositor — same pixels, but one property re-runs the pipeline while the other skips to its last stage.

    The fix. Animate transform: translate() and opacity only — they run on the compositor thread.

    box.style.left = x + "px"; // layout every frame
    box.style.top  = y + "px";
    // fix: box.style.transform = `translate(${x}px, ${y}px)`
  • Sets transition:all, then flips several properties at once.

    Why it's slow. all opts every animatable property into the transition. You meant to fade a background — you also signed up to animate margin, a layout property, so the browser reflows frame after frame through the entire 380ms transition.

    The fix. Name the exact properties: transition: transform, opacity. Never all.

    .box { transition: all 380ms; }
    /* flipping margin now animates LAYOUT for 380ms */
  • Highlights the box at the top of the view — a docs TOC tracking the active heading — by reading getBoundingClientRect for every box on every scroll event.

    Why it's slow. Two sins compound. Per event: getBoundingClientRect × N boxes, a burst of main-thread reads between frames. Across events: after the reads it moves the highlight — a DOM write — so the NEXT event's first read finds layout dirty and pays a full forced reflow. No single call looks wrong; the read→write cycle across events is the thrash.

    The fix. The expensive part is the call, not the value: getBoundingClientRect returns a frozen DOMRect snapshot that is free to read later. Cache it, throttle to requestAnimationFrame — or tick the IntersectionObserver control: same tracking, no reads at all.

    scroller.addEventListener("scroll", () => {
      for (const box of boxes) box.getBoundingClientRect(); // READ ×N, every event
      active.classList.add("tracked"); // WRITE → next event's reads pay a reflow
    });
  • Dirties layout, then reads a computed layout value per box.

    Why it's slow. getComputedStyle looks like a passive CSS lookup. It is not. Ask it for a layout-dependent value like height while layout is dirty and it forces the same synchronous reflow offsetHeight does — just wearing a different coat.

    The fix. Read computed styles once, outside the write loop — or avoid layout-dependent properties in hot paths entirely.

    box.style.paddingTop = flicker;              // WRITE
    parseFloat(getComputedStyle(box).height);    // READ → forced reflow
  • Heavy blur + shadow, repainted every frame. Watch the boxes stutter — not the FPS.

    Why it's slow. Painting big blurred shadows over a thousand boxes is genuinely expensive, but paint and raster run on the compositor thread, not the main thread. The animation stutters while the rAF-based FPS meter above stays near your refresh rate. That gap is the point: a main-thread FPS counter is blind to paint jank.

    The fix. Shrink the blur radius and the painted area, or animate transform/opacity of an already-painted layer. To actually see paint cost, open DevTools → Rendering → Frame Rendering Stats — not a rAF FPS counter.

    box.style.filter = `blur(${blur}px)`; // repaint every box, every frame
    // the main-thread FPS meter won't move — the compositor is the one drowning

Healthy controls

Not jank — the same work done right. Tick one against its jank twin above: these stay green.

  • The forced-reflow loop's exact work — batched instead of interleaved.

    Why it's cheap. Same writes, same reads, different order. All the writes land while layout is dirty anyway; the first read flushes layout once and every read after it hits the clean cache. N reflows per frame become one.

    The lesson. Tick this and the forced-reflow loop one at a time at the same box count. Identical work — one stays green, one craters. The order is the whole difference.

    for (const box of boxes) box.style.borderTopWidth = flicker; // all writes
    for (const box of boxes) read(box.offsetHeight); // all reads → ONE reflow
  • The forbidden write→read — but once, on a single box, per frame.

    Why it's cheap. This is the exact write→read the forced-reflow jank loop commits, and the metrics don't move. One flush on one element is a rounding error against the frame budget. A thousand of them in a loop is a frozen page.

    The lesson. Thrashing is not “a read after a write” — it is that pair repeated across many elements, many times per frame. The auto-growing textarea that reads scrollHeight right after a write is fine. The scale is the whole difference.

    firstBox.style.borderTopWidth = flicker; // WRITE
    read(firstBox.offsetHeight); // READ → one reflow. Once. That's fine.
  • The same moving highlight, same auto-scroll — but the browser reports the zone crossings.

    Why it's cheap. The zone is described once (root + rootMargin); from then on the browser computes intersections on its own schedule and calls back with ready-made entries. The handler only flips a class — it never asks the DOM a geometry question, so there is nothing to force. Writes still happen; the reads are gone.

    The lesson. You rarely need per-scroll rect reads to know where something is. Describe the zone once and let the browser report crossings — this is how a docs TOC should track its active heading. A real TOC refactored this way went from 25 layout reads per frame to 3.6, and from 60% thrashing frames to 6%.

    const observer = new IntersectionObserver(trackTopmostBox, {
      root: scroller,
      rootMargin: "0px 0px -85% 0px", // only the top 15% counts as "the zone"
    });
    for (const box of boxes) observer.observe(box); // zero layout reads from here on

Thrash vs batched

Click the button and two loops race on 2,000throwaway nodes, built into the strip at the bottom of this card. Both loops do identical work — set a padding on every node, then read every node's height back. The only difference is the order:

  • Thrash writes then reads, node by node. Every read finds layout dirty and forces a synchronous reflow — 4,000 reflows per run.
  • Batched does every write first, then every read. Only the first read of each pass finds layout dirty — 2 reflows per run.

Each loop runs 5 times and the bars report the median. If the tab freezes for a moment, that is the experiment working: the thrash loop really is blocking the main thread.