Browser Rendering Pipeline

The browser turns DOM + CSS into pixels through five stages: Style → Layout → Paint → Composite. Knowing which CSS properties trigger which stages — and avoiding layout thrashing — is the core skill behind smooth 60fps UI.

A 60Hz display refreshes every 16.66ms, and the browser has its own overhead, so the practical budget to produce a frame is ~10ms. Miss it and the frame rate drops and content judders — "jank." Responsiveness to discrete interactions is captured by the INP metric (good ≤ 200ms). Source: web.dev, https://web.dev/articles/rendering-performance, 2026-06-12

The five stages

Stage What happens
JavaScript Triggers visual change (DOM edits, class toggles, WAAPI). CSS animations/transitions can also drive it.
Style Match selectors to elements, compute final styles.
Layout Compute geometry — size and position. One element's width can reflow the whole subtree.
Paint Fill pixels: text, colors, images, borders, shadows — onto layers (includes rasterization).
Composite Assemble the layers in the correct order onto the screen.

Source: web.dev, 2026-06-12

Three paths through the pipeline

Not every frame touches every stage. Which property you change decides the path:

  1. Layout property (width, height, top, left, …) → Style → Layout → Paint → Composite. Most expensive: forces a reflow.
  2. Paint-only property (background-image, color, box-shadow, …) → Style → Paint → Composite. Skips layout.
  3. Composite-only property (transform, opacity) → Style → Composite. Cheapest and the only safe path for animation/scroll. Source: web.dev, 2026-06-12

Animate transform and opacity, never top/left/width/height. Chromium also runs page scrolling on the compositor thread, which is why a janky page can still scroll.

Layout thrashing (forced synchronous layout)

Layout thrashing is reading a layout value (offsetWidth, getBoundingClientRect(), scrollTop) after writing to the DOM, in a loop. Each read forces the browser to flush a synchronous layout because the previous write invalidated it, turning one layout into dozens. Tiger Abrodi's self-thread points to his longer article, which gives the same practical fix: batch DOM reads before writes, use off-DOM fragments for bulk changes, and avoid unnecessary reflow triggers. Source: X/@TAbrodi thread, 2026-03-28 (911 likes, 707 bookmarks); Source: Tiger Abrodi blog, 2024-02-27.

// BAD: read → write → read → write — forces layout every iteration
for (const el of items) {
  el.style.width = el.offsetWidth + 10 + "px"; // read flushes layout after each write
}

// GOOD: batch all reads, then all writes (the FastDOM pattern)
const widths = items.map((el) => el.offsetWidth);     // reads
items.forEach((el, i) => (el.style.width = widths[i] + 10 + "px")); // writes

Caching getBoundingClientRect instead of re-reading it per pointer/zoom event is the same fix applied to a hot interaction loop — exactly what the procedural Canvas renderer in Princeton Tower Defense does to stay smooth.

Practical rules

  • Animate only transform / opacity; hint long-lived animated layers with will-change sparingly.
  • Batch DOM reads and writes; never interleave them in a loop.
  • Avoid transition: all — it can transition layout properties you didn't intend.
  • Reduce paint area and layer count; promote intentionally, not reflexively.

These underpin the interruptible-animation and motion guidance in Interface Micro-Polish and Motion Animation Style, and the scroll-timeline work in Scroll-Driven Animations (CSS) (which runs on the compositor).


Timeline