Rendering

Keep every frame under 16 ms: know which stages a change triggers, and stop asking the browser for a measurement you just invalidated.

09of 16
≈ 12Min read
5Parts

The budget is 16.7 milliseconds

At 60 Hz the browser has 16.7 ms to produce a frame, and part of that is already spoken for. Miss it and the frame is dropped — one dropped frame is a stutter, several in a row is what everyone calls "janky". On a 120 Hz screen the budget is 8.3 ms.

Everything in this lesson is about doing less inside that window.

The pixel pipeline

Each stage forces the ones after it. Change a width and you pay for layout, paint and composite; change a transform and you pay only for composite — and that part runs on the GPU. This is the whole reason animations should use transform and opacity and nothing else.

StageWhat it doesTriggered by
StyleWork out which rules applyany class or style change
LayoutWork out where everything goeswidth, height, top, margin, font-size, adding a node
PaintFill in the pixelscolor, background, box-shadow, border-radius
CompositePut the layers on screentransform, opacity, filter

Layout thrashing

The browser batches style changes and does layout once, at the end of the frame — unless you ask it a question that needs the answer now. offsetWidth, getBoundingClientRect(), scrollTop, getComputedStyle() are all questions like that. Ask one after a write and the browser has to lay the whole page out on the spot.

The fix is never to make the code faster. It is to put all the reads before all the writes.

the classic mistake
/* every read after a write forces a synchronous layout — N times */
for (const box of boxes) {
  box.style.width = box.offsetWidth + 10 + "px";
}

/* read everything, then write everything — one layout */
const widths = boxes.map((box) => box.offsetWidth);
boxes.forEach((box, i) => { box.style.width = widths[i] + 10 + "px"; });

Doing less per frame

  • content-visibility: auto — the browser skips layout and paint for off-screen sections entirely. Pair it with contain-intrinsic-size so the scrollbar is still honest.
  • contain: layout paint — promises that nothing inside affects anything outside, so the browser can scope the work.
  • Virtualise long lists — 10 000 rows in the DOM is slow at every stage; render the forty that are visible.
  • will-change: transform on the element about to animate — and remove it after. It reserves a layer; leaving it on everything costs memory and can make things worse.
  • requestAnimationFrame for anything visual, never setInterval. And ResizeObserver / IntersectionObserver instead of measuring in a scroll handler.

Passive listeners and scrolling

Without passive: true the browser must wait for your handler in case it calls preventDefault(), so scrolling stalls behind your JavaScript. With it, scrolling happens on the compositor and your handler runs alongside. For touchstart, touchmove and wheel it is the difference between a page that follows the finger and one that does not.

js
window.addEventListener("scroll", onScroll, { passive: true });

Practice