Rendering PipelineApr 19, 2026·10 min read·

From JS to pixel: the browser's rendering pipeline in five stages

Every senior FE dev says 'render' like it's one thing. The browser's pipeline is five distinct stages — JS, style, layout, paint, composite — each with its own cost curve and its own short-circuit rules. Here's the stage-by-stage walk, with a property-to-stage mapper you can tap.

Play through a single frame — what the browser does between your handler returning and the pixel hitting the screen:

one frame · left → right
vsync budget: ~16.6ms @ 60Hz
JavaScript
stage 1 / 5

Your handlers, rAF callbacks, timers, and framework commit phases (React commitRoot) run here. This is where the DOM is mutated — React writes nodes, your code changes classes, etc.

Auto-playing. Click any numbered stage to jump.

"Render" is not one operation. It's a pipeline of five stages — JavaScript, Style, Layout, Paint, Composite. Each has different cost, different triggers, and different ways to skip it. Fast UI comes from knowing which stage your mutation lands in, and arranging things so the expensive stages don't re-fire every frame.

This post walks the pipeline stage by stage. It reads the primary sources. It ends with a tap-to-inspect tool that tells you exactly which stages a given CSS property change will re-fire.

tl;dr

The pipeline goes: JS (handlers & commits) → Style (selector matching) → Layout (geometry) → Paint (fill pixels into layers) → Composite (GPU stacks the layers and the display presents). There are two short-circuits: paint-only mutations (like color, background) skip layout; compositor-only properties (transform, opacity, when the element is on its own layer) skip both layout and paint. Animating only those two properties is how 60fps UIs exist.

The one-per-frame contract

Before the five stages, the scheduling rule. WHATWG's HTML Living Standard defines "update the rendering" as a task the browser queues once per rendering opportunity — typically once per vsync on a 60 Hz display. Its algorithm is where the pipeline actually lives:

For each navigable that has a rendering opportunity, queue a global task on the rendering task source given navigable's active window to update the rendering: ... run the resize steps ... run the scroll steps ... evaluate media queries ... update animations and send events ... run the animation frame callbacks ... recalculate styles and update layout ...

That algorithm is a contract. All rendering work, for every navigable on the event loop, happens inside it — once per frame. The pipeline stages are what the browser actually does to fulfil that contract.

Stage 1 — JavaScript

This is the only stage you write directly. Everything else is the browser reacting to what you did here.

  • Event handlers, timers, promise callbacks, requestAnimationFrame callbacks.
  • Framework commit phases (React's commitRoot, Vue's flush, Svelte's flush) — anything that writes to the DOM.
  • Direct DOM API calls (appendChild, classList.toggle, style.setProperty, textContent).

JS time is "your time". Two pathologies live here:

  1. Long-running handlers — anything over 50ms blocks user input. That's the Long Tasks API threshold, the standard marker for "this is too long".
  2. Forced layouts — reads like offsetWidth after writes trigger a full style + layout pass inside your task, before your code continues. That's the layout-thrashing post's story: mid-task flushes that don't belong on the one-per-frame budget.

A good JavaScript stage writes mutations, yields, and lets the pipeline run. A bad one reads a CSSOM property between writes, then writes again, then reads again. Each read is another pipeline re-entry.

Stage 2 — Style Recalc

Once JS hands control back, the browser starts work to turn the DOM it now holds into pixels. First job: for every element the JS pass dirtied, figure out which CSS rules apply and what the final computed style is.

web.dev describes this directly:

Style calculations: This is the process of figuring out which CSS rules apply to which HTML elements based on matching selectors.

Selector matching is the bulk of the cost. Broad descendant selectors (.foo .bar .baz *) force the engine to walk the tree; specific class selectors are cheap. The browser also runs the cascade and inheritance here — ECMAScript's getComputedStyle() is a spec-level way to reach into this stage, and it forces a recalc if the styles are dirty.

The output is every affected element's computed style: a flat map of property → resolved value. Layout hasn't happened yet — the browser knows width is 50% or content-box but hasn't measured anything.

Stage 3 — Layout

Given computed styles, the browser now calculates geometry: where each box goes, how big it is, how line breaks fall, how flex and grid distribute space.

Layout: Once the browser knows which rules apply to an element it can begin to calculate the geometry of the page, such as how much space elements take up, and where they appear on the screen.

This is the most expensive stage on most pages. A layout pass is at least O(dirty subtree). Often worse — a width change on an ancestor cascades and forces every descendant to re-flow. This is also the stage forced by CSSOM reads like offsetWidth and getBoundingClientRect(). Calling them on a dirty document runs style + layout inline, right there inside your task.

Tricks that skip or shrink layout:

  • Don't change layout-affecting properties. Prefer transform: translate() over top/left; opacity over visibility changes that affect surrounding flow.
  • Contain sub-trees. contain: layout tells the engine that a sub-tree's layout can be computed in isolation; a change inside the container can't escape it.
  • Promote animations out of layout. will-change: transform hints the compositor to lift the element onto its own layer, so animating its position never re-lays-out the parent.

Stage 4 — Paint

Once layout is done, the browser has a list of rectangles with computed styles. Now it turns those into actual pixels — filling text, borders, backgrounds, shadows, images, gradients — into painted surfaces.

Paint: Painting is the process of filling in pixels. It involves drawing out text, colors, images, borders, shadows, and essentially every visual aspect.

Paint is not one big operation. The browser usually splits the page across multiple layers. A fixed-position element, an element with will-change: transform, or a transform3d element each tend to become their own layer. Paint only touches dirty layers, so a localised repaint is cheap. A repaint of the whole-page layer is not.

Paint-only mutations — changes that keep geometry identical but change colour or shading — skip layout but must still repaint:

  • color, background-color, border-color.
  • box-shadow (sort of — large shadows are expensive because the painted area grows).
  • visibility (the element still takes up layout space; only its paint output changes).

Stage 5 — Composite

The final stage is stacking. The browser takes every painted layer and assembles them into a single frame using the GPU: translating layers by their transforms, applying their opacity, respecting stacking order.

Composite: Since the parts of the page were potentially drawn onto multiple layers, they need to be applied to the screen in the correct order so that the page renders correctly.

This is where "compositor-only" properties get their magic. Per web.dev:

Today there are only two properties for which that is true — transforms and opacity.

Two properties. Only these — when the element is on its own compositor layer — can animate without ever re-entering style, layout, or paint. The compositor already has the painted layer. Translating it by a new transform is a matrix multiply on the GPU. No main-thread CPU work. That's why animating transform is cheap and animating top/left is not.

Tap to see what your mutation costs

Pick any property and the five-stage pipeline will light up with the stages it triggers. The three short-circuit tiers — full pipeline, paint-only, compositor-only — are the model you want in your head the next time you reach for an animation or a style toggle.

pick a property to mutate
stages that fire when width changes
layout tier
JS
fires
style
fires
layout
fires
paint
fires
composite
fires
Geometry changes size — every descendant may re-layout.
Three tiers: layout-tier props re-fire the full pipeline; paint-tier skip layout; compositor-only (transform / opacity, on their own layer) skip both layout and paint.

Some concrete takeaways from playing with it:

  • width and height fire the full pipeline. A .container { width: 50% → 60% } mutation is as expensive as it looks.
  • color and background skip layout but still repaint. That's why a hover state on a button with a color transition feels instant — no layout — and a width animation on the same button feels janky.
  • transform and opacity skip both layout and paint. They are, practically, the only two CSS properties you can animate at 60fps on a busy page.

Where the short-circuits come from

The three tiers above aren't folklore; they come out of the pipeline's shape. A property's tier is a function of how deep into the pipeline a change to it can escape:

  • Layout-affecting — anything whose value changes geometry (size, flow, break, grid span). Must re-layout, which forces paint, which forces composite.
  • Paint-only — anything that changes pixel output but not geometry (colour, shadow spread, border colour with same border-width). Skips layout; paint and composite still fire.
  • Compositor-only — anything expressible as a layer transformation on the GPU (translate, rotate, scale, opacity). If the element is already on its own layer, the compositor can apply the change without the main thread ever running style/layout/paint.

That last bullet is why the "only two properties" list is short. A property can only skip paint if the compositor knows how to apply it to an already-rasterised layer. Translation and opacity, yes. Filter effects, text colour, border radius — no; the rasterisation would change, so paint has to run.

What this buys you

A precise pipeline model replaces a lot of folklore with mechanism:

  • "Why is this hover slow?" — The hover changes a layout-affecting property. Every pointer movement re-fires style + layout + paint + composite. Swap to a paint-only or compositor-only trigger.
  • "Why is my scroll animation janky?" — Your scroll handler is probably writing a layout-affecting property. The main thread is doing layout and paint inside the scroll event. Move to transform: translate3d() on an element with will-change: transform.
  • "Why does my 60 fps animation look fine on Chrome but stutter on Safari?" — Compositor layer promotion heuristics differ. You think you're on your own layer; you're not. Add will-change: transform or transform: translate3d(0,0,0) to force promotion and verify in DevTools' Layers panel.
  • "Why does getComputedStyle() in my handler tank scroll performance?" — It's a sync entry into style recalc. Every call is a pipeline re-entry on the main thread.

The pipeline diagram is not something you look at once. It's the model you run every time you write a CSS rule that might animate, or a DOM mutation that might cascade. "Which stage does this land in?" is the single most useful performance question in frontend — and once you can answer it, the rest of the perf advice you've read slots into its actual place.

Primary sources