../

Browser rendering pipeline

How a browser turns HTML, CSS and JavaScript into pixels, and which of your changes make it redo which work. Almost every front-end performance fix is "skip a stage" or "stop blocking a stage". The metrics that score the result are on Core Web Vitals.

bytes from the network HTML parser CSS parser DOM CSSOM Style Layout Paint Composite JavaScript computed styles geometry draw lists layers → frame main thread: parse, script, style, layout, paint compositor + GPU reads and changes the DOM and CSSOM, so Style runs again
From bytes to pixels
StageInput → outputRuns on
NetworkURL → bytes (DNS, TCP/QUIC, TLS, HTTP)network process
Parse HTMLbytes → tokens → DOM treemain thread
Parse CSSstylesheets → CSSOMmain thread
Scriptfetch, compile and run JS; may change DOM/CSSOMmain thread
Style (recalc)DOM + CSSOM → computed style per elementmain thread
Layout (reflow)computed styles → boxes with sizes and positionsmain thread
Paintboxes → display lists (draw commands) per layermain thread
Rasterdisplay lists → pixel tilesraster/GPU threads
Compositelayers + transforms/opacity → the framecompositor thread + GPU

"Render tree" is the old name for the style + layout output: only elements that generate boxes (display: none elements and <head> don't). The main thread does parsing, script, style, layout and paint, so a busy main thread delays all of them. The compositor can still scroll and run transform/opacity animations while the main thread is blocked.

Parsing & the preload scanner

FactConsequence
HTML parses incrementally as bytes stream inflush the <head> early; stream the rest
A classic <script> (no async/defer) stops the parserthe DOM after it waits for download + execution
A script also waits for pending stylesheets before runningCSS can block JS, which blocks the parser
The preload scanner reads ahead for src/href while the parser is blockedresources in the markup are found early
The scanner can't see resources created by JS or CSSbackground-image, @import, JS-injected <img> load late
document.write of a script forces a synchronous fetchnever use it

Make the important resources visible to the scanner: real <img src> and <link rel="stylesheet"> in the HTML, not data-src swaps or CSS backgrounds for the hero image. If a resource can only be discovered late, hint it (see Network & loading).

Render-blocking vs parser-blocking

HTML parser styles.css <script> defer async paused waits for the parser first paint DOMContentLoaded outline: download · blue: parse HTML · purple: parse CSS · red: run script
What blocks the parser and what blocks the first paint
ResourceBlocks parsing?Blocks rendering?Runs / applies
<link rel="stylesheet">noyes (in <head>)when parsed
stylesheet with non-matching medianonodownloaded at low priority
<script src> (classic)yesyes, indirectlyimmediately, in order
<script defer src>nonoafter parsing, in order, before DOMContentLoaded
<script async src>only while runningnoas soon as loaded, any order
<script type="module">no (deferred by default)nolike defer; add async for ASAP
inline <script>yesyesimmediately (waits for earlier CSS)
<img>, fonts, fetchnonowhenever they arrive

Critical rendering path: the minimum work before the first paint = HTML up to the content + all render-blocking CSS (+ any parser-blocking scripts before it). Shorten it by:

  • keeping render-blocking CSS small (inline the critical CSS, load the rest non-blocking);
  • deferring scripts or using modules, and putting nothing parser-blocking in <head>;
  • cutting round trips: same-origin assets, preconnect for the one or two critical third-party origins.

Script loading attributes in depth: Document head § Script loading.

Style, layout, paint, composite

StageCost grows withKeep it cheap
Styleelements affected × matching ruleschange classes low in the tree; fewer, simpler rules touching big subtrees
Layoutnumber of boxes whose geometry changesavoid layout-affecting changes on big subtrees; contain them
Paintarea repainted × effect cost (shadows, blur, gradients)smaller repaint areas; avoid animating box-shadow/filter on large boxes
Compositenumber and size of layerspromote only what animates

Selector matching is rarely the bottleneck today; the number of elements restyled is. A class toggled on <body> restyles the whole page; the same class on one card restyles one card.

What each property triggers

Style Layout Paint Composite computed values sizes, positions fill in pixels move layers (GPU) skip skip skip skip width height top margin grid-* expensive color background box-shadow moderate transform translate scale opacity cheap every frame reruns each stage from the first one the property triggers
Which stages each kind of property triggers
Changing…RerunsExamples
geometrystyle → layout → paint → compositewidth, height, padding, margin, border-width, top/left, font-size, display, text content
appearance onlystyle → paint → compositecolor, background-*, box-shadow, outline, border-color, visibility
layer transform / opacitystyle → compositetransform, translate, rotate, scale, opacity
filterstyle → paint → composite; composite-only while animated on its own layer (Chromium)filter, backdrop-filter

clip-path usually repaints. CSS animations and transitions of transform/opacity run on the compositor and skip style on each frame (as the diagram shows); setting them from script restyles first (as the table shows). The exact list varies by engine and version (the old CSS Triggers site is years out of date). Measure in the DevTools Performance panel: purple = style/layout, green = paint. For animations, stick to transform and opacity; see Animation § Performance.

Layers & the compositor

The compositor thread moves and fades layers (GPU textures) without the main thread. An element gets its own layer when it has an active transform/opacity animation, will-change, is a <video> or <canvas>, uses position: fixed in some engines, or overlaps another layer.

ToolUseCost
will-change: transformpromote before an animation startsGPU memory per layer; keep it off idle elements
transform: translateZ(0)old promotion hacksame as above, less clear; prefer will-change
DevTools → Layers panelsee layers, sizes and why each exists—
Rendering tab → Layer bordersoutline layers on the page—

Too many layers ("layer explosion") costs memory and compositing time, especially on phones. Add will-change just before animating and remove it after, or set it only on elements that animate constantly.

The frame: event loop & 16.7 ms

16.7 ms smooth long task every frame: a short task, then rendering, then idle time one 120 ms task: no frames, input waits 7 frames dropped; the next frame renders only after the task ends blue: JS task · purple: rAF, style, layout · green: paint · red: long task
A frame's budget, and what a long task does to it

One turn of the event loop, per the HTML spec:

  1. Run one task (a script, an event callback, a setTimeout callback, a message).
  2. Run all microtasks (promise reactions, queueMicrotask, MutationObserver) until the queue is empty.
  3. If it's time to render (roughly every display refresh, only when something changed): resize/scroll events → requestAnimationFrame callbacks → style → layout → ResizeObserver → paint → composite.
  4. Idle time left over → requestIdleCallback callbacks.
Refresh rateFrame budgetYour share (≈ budget minus browser work)
60 Hz16.7 ms~10 ms
90 Hz11.1 ms~6 ms
120 Hz8.3 ms~4 ms

A long task is any task over 50 ms; while it runs, nothing renders and input waits. That is what drives a bad INP. Breaking long tasks up and yielding is on JavaScript & the main thread.

Where each callback runs
const log = (s: string) => console.log(s);
setTimeout(() => log("task"), 0);
queueMicrotask(() => log("microtask"));
requestAnimationFrame(() => log("before next paint"));
Promise.resolve().then(() => log("microtask 2"));
log("sync");
// sync → microtask → microtask 2 → then "task" and
// "before next paint" in either order (depends on
// when the next frame is due)

Forced synchronous layout & thrashing

Style and layout are lazy: the browser batches DOM writes and computes layout once, before paint. Reading a geometry value after a write forces it to compute layout now (a forced synchronous layout). Doing that in a loop (write, read, write, read…) is layout thrashing.

Thrashing: one layout per iteration
for (const el of items) {
  const w = box.offsetWidth; // read → forces layout
  el.style.width = `${w / 2}px`; // write → invalidates
}
Batched: one layout total
const w = box.offsetWidth; // all reads first
for (const el of items) {
  el.style.width = `${w / 2}px`; // then all writes
}
Reads that force layout (when styles are dirty)
element geometryoffsetTop/Left/Width/Height, clientTop/Left/Width/Height, getBoundingClientRect(), getClientRects()
scrollingscrollTop/Left/Width/Height, scrollIntoView(), scrollBy/To()
computed stylegetComputedStyle(el) then reading a layout-dependent value
windowinnerWidth/Height, scrollX/Y
otherfocus(), innerText, Range.getBoundingClientRect(), elementFromPoint()

In DevTools a forced layout shows as a purple Layout bar inside a script's call stack with a red corner and a "Forced reflow" warning. ResizeObserver and IntersectionObserver give you sizes and visibility without forcing a layout.

Containment

Tell the browser a subtree is independent, so a change inside it doesn't make it re-check the rest of the page, and offscreen subtrees can be skipped entirely.

PropertyEffect
contain: layoutthe element's inside can't affect outside layout
contain: paintdescendants don't paint outside its box (clips like overflow: clip)
contain: sizeits size ignores its children (you must give it one)
contain: stylecounters and quotes don't escape
contain: contentlayout paint style: safe default for widgets
contain: strictcontent + size
content-visibility: autoskip style, layout and paint of the subtree while it's offscreen
content-visibility: hiddenalways skip rendering but keep the state (a cheap display: none that keeps layout cached)
contain-intrinsic-size: auto 500pxplaceholder size while skipped; auto remembers the last real size

content-visibility is Baseline 2024 (Safari 18). content-visibility: auto on long pages (feeds, docs, comment threads) can cut initial rendering work by a large factor. Always pair it with contain-intrinsic-size, or the scrollbar jumps as sections render. Content stays in the DOM, so find-in-page, anchors and assistive tech still work.

Common mistakes

MistakeFix
Classic <script> in <head>defer or type="module"
Hero image as a CSS background-imagereal <img> (visible to the preload scanner)
@import inside CSS<link> each file, or bundle them; @import chains round trips
Animating top/left/widthanimate transform (see Animation)
Reading offsetHeight in a loop that writes stylesread everything first, then write
will-change on dozens of elements "for speed"only on elements about to animate
Toggling a class on <body> to restyle one widgettoggle it on the widget
A 1,000-row list fully renderedcontent-visibility: auto or virtualize the list
Doing heavy work in a scroll handlerrequestAnimationFrame or an observer

Recipes

Batch DOM reads and writes per frame

Queue reads and writes separately and flush them together, reads first, once per frame.

dom-batch.ts
type Job = () => void;
const reads: Job[] = [];
const writes: Job[] = [];
let scheduled = false;
 
function run(jobs: Job[]): void {
  for (const job of jobs.splice(0)) {
    try {
      job();
    } catch (err) {
      reportError(err); // one bad job can't stall the queue
    }
  }
}
 
function flush(): void {
  scheduled = false;
  run(reads);
  run(writes);
  // jobs queued by the writes run next frame
  if (reads.length || writes.length) schedule();
}
 
function schedule(): void {
  if (!scheduled) {
    scheduled = true;
    requestAnimationFrame(flush);
  }
}
 
export function measure(job: Job): void {
  reads.push(job);
  schedule();
}
 
export function mutate(job: Job): void {
  writes.push(job);
  schedule();
}

rAF-throttled scroll handler

Do scroll-driven work at most once per frame, with a passive listener that never blocks scrolling.

on-scroll.ts
export function onScrollFrame(
  handler: (y: number) => void,
): () => void {
  let pending = false;
  const listener = (): void => {
    if (pending) return;
    pending = true;
    requestAnimationFrame(() => {
      pending = false;
      handler(window.scrollY);
    });
  };
  addEventListener("scroll", listener, { passive: true });
  return () => removeEventListener("scroll", listener);
}

Skip offscreen sections

Each section renders only when it nears the viewport; auto remembers its real height afterward.

long-page.css
.feed > article {
  content-visibility: auto;
  contain-intrinsic-size: auto 480px;
}

Promote ahead of an animation

Set the layer hint when an animation becomes likely (hovering the trigger), so the layer exists before the first frame, and drop it when the animation ends or is canceled.

animate-panel.ts
export function prepare(trigger: Element, el: HTMLElement) {
  trigger.addEventListener("pointerenter", () => {
    el.style.willChange = "transform";
  });
}
 
export async function slideIn(el: HTMLElement) {
  const anim = el.animate(
    [
      { transform: "translateX(100%)" },
      { transform: "translateX(0)" },
    ],
    { duration: 250, easing: "ease-out" },
  );
  try {
    await anim.finished;
  } catch {
    // canceled: nothing more to do
  } finally {
    el.style.willChange = "auto";
  }
}

Watch size without forcing layout

ResizeObserver delivers sizes after layout, inside the rendering steps, instead of reading them in a loop.

observe-size.ts
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { inlineSize } = entry.contentBoxSize[0];
    entry.target.classList.toggle("wide", inlineSize > 600);
  }
});
ro.observe(document.querySelector(".card")!);

References