From HTML to pixel: how the browser renders a page

Every DOM element goes through five stages before you see it, regardless of the framework. Some styles trigger only the last stage; others trigger all five. That's the difference in animation cost - from a single composite step to a full reflow.
Two animations that look identical:
@keyframes slide-a {
to {
transform: translateX(100px);
}
}
@keyframes slide-b {
to {
left: 100px;
}
}Box A runs at 60 fps on any laptop. Box B on the same hardware can stutter and freeze the scroll. Same movement, different style, different cost. Why?
Under the hood of every DOM element sits a pipeline with five stages: parse → style → layout → paint → composite. Each can fire on its own or together with others, depending on what exactly you change. This is the layer below every framework - whether you write React, Vue, Astro, or vanilla JS, the browser does the same thing. The framework choice decides when you trigger the pipeline, not how it works.
After walking through these five stages, it’ll be clear why transform is cheap and width is expensive - and what to do when your 60 fps drops to 15.
#Parsing: HTML and CSS run in parallel
The first stage kicks in the moment the server starts sending its response. The browser receives data as a stream - HTML arrives chunk by chunk, CSS comes in from separate requests. The HTML parser and the CSS parser work simultaneously, each on its own stream, each building its own tree.
The HTML parser breaks the incoming text into tokens - the smallest recognizable pieces of the document: an opening tag (<div>), a closing tag (</div>), an attribute (class="foo"), text between tags. From these tokens it constructs the DOM (Document Object Model) - the tree of elements you later manipulate from JS (document.querySelector, element.appendChild, and so on). Crucially, the parser works incrementally - it doesn’t wait for the whole HTML to arrive from the server, it builds the tree on the fly. As soon as it sees <body>, it starts constructing its children. It can render a partial tree before the full HTML arrives. That’s why you see a page build up from the top on a slow connection.
The CSS parser does an analogous thing with stylesheets: every CSS file, every <style> tag, and every style attribute is broken into tokens (same rules as HTML, just different syntax - selectors, properties, values) and assembled into the CSSOM (CSS Object Model). There’s one key difference, though: CSSOM is render-blocking. The browser won’t render anything until it finishes parsing every stylesheet, because any new selector might override styles it already knows. Imagine the scenario: the parser starts painting the header in green, and at the end of the last CSS file there’s h1 { color: red }. Without a complete CSSOM, there’s no way to know which rule wins the cascade.
That’s where the critical CSS technique comes from - styles needed to render the first screen (typography, hero section layout, background colors) go straight into <head> inside a <style> tag, the rest loads separately. The browser doesn’t have to wait for an external style.css to show the hero section.
The spec describes this in detail. The HTML Living Standard - section 13.2 Parsing spells out what “mode” the parser is in at any given moment: reading plain text, waiting for a tag name after seeing <, reading an attribute, checking whether <! starts a comment or <!DOCTYPE>. Each character moves the parser into a different mode, and once it has a recognized chunk of the document (a tag, an attribute, a piece of text), it hands it off as a finished token to the DOM constructor. An analogous model for CSS is described in CSS Syntax Module L3.
#Style: cascade, computed values, render tree
With DOM and CSSOM in hand, the browser has to combine them and answer the question: “which element of the DOM tree ends up with which styles - and what concrete values (color, font-size, margin, padding, and so on)?”. The result is the render tree (called the layout tree or frame tree in some engines).
The render tree is not just DOM plus styles. It’s the filtered tree of visible nodes. There’s no <head>, no <script>, nothing with display: none. There is, however, everything with visibility: hidden - because it still takes up space in the layout, just invisible.
For each visible element, the browser computes the computed value of every CSS property - that is, the final value with which it will be rendered. This is a three-step process:
1. Selector matching - matching rules to elements. CSS is written “from the rule side”: you say “elements with class .btn should have a blue background”. But the browser looks at it the other way - it takes a concrete element from the DOM tree and asks: “which of the hundreds of selectors in all the stylesheets apply to me?”. That’s selector matching. Take a concrete example:
.modal .btn {
background: blue;
}(.btn is a typical class name convention for buttons, .modal for a modal dialog - it’s “every button inside a modal”). Question: to which elements on the page does this rule apply?
The engine most often goes right-to-left. First it gathers all elements with class .btn (there are usually only a few, easy to look up via a class map), then for each one it checks whether somewhere higher in the DOM tree there’s an ancestor with class .modal. If the engine went the other way (top-down from .modal), it would have to scan the entire subtree of each modal looking for buttons - far more work.
2. Cascade - resolving conflicts. For the same property on the same element, several competing values may exist (e.g. color):
- the browser’s default style (
a { color: blue }in the user agent stylesheet, UA for short) - a user style (custom CSS added by, say, a browser extension)
- your CSS - the so-called author stylesheet (
.link { color: green }) - an inline
<a style="color: red">in the attribute
Only one value will make it to the computed value. The rules in CSS Cascade and Inheritance Level 5 pick the winner by three criteria, in this order:
- Origin (where the rule comes from) - inline style beats author (your CSS), author beats user style, user style beats the browser’s default.
- Specificity (how precise the selector is) -
#hero .title(ID + class) beats.title(class only), which beatsp(tag only). The more precise the selector, the higher its specificity. - Order - if everything else is equal, the rule written later in the CSS file wins.
3. Inheritance - some properties (color, font, line-height) inherit their value from the parent if not explicitly defined on the element. Others (margin, padding, width) don’t inherit, and start from their initial value (the spec’s default for that property).
The result: every element in the render tree gets a final value for every property. After this step, the browser knows that “h1 has color rgb(220, 38, 38), font-size 32px, margin-top 24px”. But it doesn’t yet know where that h1 will sit on the screen or how big exactly it will be. That’s left for the next stage.
#Layout: where and how big
The layout stage (sometimes called reflow) answers a simple question, asked for every element in the render tree: “where are you and what are your dimensions?”. The result is the geometry of every box - its position (x, y) and dimensions (width, height) expressed in pixels.
Seemingly trivial, in reality the hardest part. Layout is viewport-relative (it depends on the window size - different for mobile, different for 4K) and flow-aware (every element has to know where its neighbours, siblings, parent, and children are). Changing one element often means recomputing an entire subtree, sometimes the whole page.
Picture a grid of cards. You enlarge the first card by 20 pixels in height. The browser has to:
- Recompute that card (its dimensions changed)
- Push the next card down (it’s a sibling in the same flex container)
- Check whether the rest of the grid still fits (maybe a new row is needed)
- Update the scroll height of the whole document (the content is taller now)
- Check whether the position of any sticky element needs updating
A single style.height = '120px' in JS is potentially five recalculations. That’s why layout is most often the most expensive stage of the pipeline - and why optimization frequently boils down to “do less layout”.
CSS Display Module Level 3 formally defines the box model (block / inline / flex / grid layout), and CSS Box Sizing Module Level 4 describes how width, height, min-content, max-content, fit-content are computed. These two documents are the starting point if you want to dig deeper into the mechanics.
#Paint: from geometry to image
After layout, the browser knows what should be on the screen and where. The paint stage (called repaint when it refers to drawing again) fills that information with pixels.
The browser doesn’t draw straight to the screen - first it creates paint records, a list of instructions like “draw a rectangle of these dimensions, color X, with radius Y; then draw text…”. These instructions are executed sequentially, in stacking order - z-index, position, parent-child, flow-relative.
Paint is also logically split into paint layers. Each layer is a separate bitmap of pixels (like a layer in Photoshop) that can be painted and moved independently. Most of the page sits together on one main layer. Some elements - those with transform, opacity, will-change, position: fixed (and a few other flags) - get promoted to their own layer (Chrome, or to be exact its rendering engine Blink - a fork of WebKit from 2013 - calls this process “layer promotion”). Why the split? Because each layer can be processed independently of the others - and that’s the bridge to the next stage.
A repaint can be wide or narrow in scope. Sometimes it’s the whole viewport (a background change on two ancestors), sometimes a single 50×50 rectangle (a color change on hover). The browser tries to paint only the region that changed, but it can’t always do so. If you change box-shadow on an element covering half the screen, paint will have half the screen of work.
#Composite: GPU assembles the image
The final stage is compositing. The compositor takes all the paint layers and assembles them into a single final image that lands on the screen. This work is done by the GPU (graphics processing unit, specialized for parallel pixel processing), not the CPU (central processing unit, the main processor, good at sequential work) - and this is the key to smooth animations.
If an element sits in its own layer (thanks to transform, opacity, will-change and so on), the compositor can move, rotate, scale, or change the transparency of that layer without going back to layout and paint. It simply draws the same layer in a different place, at a different angle, with a different opacity. The GPU does this much faster than the CPU, because it has dedicated units for the job.
This explains the observation from the start of the post: transform: translateX(100px) is cheap, left: 100px is expensive. transform operates only at the compositor level (GPU). left changes position in the layout, which fires every stage below it - layout, paint, composite. Three stages vs one.
That’s where the “animate only transform and opacity” rule comes from. It’s not a myth - it’s a direct consequence of the pipeline architecture. Any other property (top, left, width, height, margin, padding, color, background) requires at least a repaint, and most often a reflow as well.
will-change is a contract with the browser - you tell it upfront “this element is about to be animated, give it its own layer in advance so you don’t have to do layer promotion during the animation”. Used wisely, it brings smoothness; abused (will-change: * on everything), it wastes GPU memory and can hurt. MDN puts it bluntly: “will-change is intended to be used as something of a last resort, in order to try to deal with existing performance problems”. For the mechanics of the property itself, see CSS Will-Change Module Level 1.
#What this means in practice: reflow vs repaint vs composite
Every CSS property you change at runtime triggers a specific subset of the pipeline stages. Click any property to see which stages light up:
The compositor moves, rotates, or scales the existing layer on the GPU - no layout, no paint.
Three rough categories by time cost (the more pipeline stages a change fires, the longer it takes):
Composite only (cheapest, fit within 16 ms per frame): transform, opacity. Only these two - everything else touches at least paint. You can animate at 60 fps.
Paint + composite (medium): color, background, outline, visibility, filter. The browser has to repaint the element’s pixels, but the layout doesn’t change. Still acceptable for animation if the affected region is small - with the caveat that filter: blur() with a large radius or drop-shadow covering half the screen can be expensive even without a reflow.
Layout + paint + composite (slowest): width, height, top, left, margin, padding, font-size, display, box-shadow. Each of these changes triggers a reflow - and a reflow can propagate across half the tree. box-shadow ends up here because classically spread and offset change the bounding box (the rectangular area an element occupies) - though modern Chrome can sometimes optimize this down to paint only.
The full list of which property triggers what is documented at csstriggers.com. One of those references worth bookmarking.
#Layout thrashing
A classic trap. Imagine this code:
for (const el of elements) {
el.style.width = el.offsetWidth + 10 + "px";
}Looks harmless. It’s absurdly slow. Why?
In every iteration of the loop you do two things:
- read -
el.offsetWidthasks the browser “how many pixels wide is this element right now?”. To answer that, the browser needs an up-to-date layout. - write -
el.style.width = ...sets a new width. After that, the layout is out of date - the browser marks it as needing recalculation.
The trouble is that a read-write-read-write sequence forces the browser to recalculate the layout immediately after every write, because the next read needs a fresh value. This is called forced synchronous layout (sometimes layout thrashing), and it can turn a 100-element loop into 100 separate reflows.
Fix: separate reads from writes. First all reads, then all writes:
const widths = elements.map((el) => el.offsetWidth);
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + "px";
});A single reflow is cheap. What’s expensive is n reflows where one would do.
#The pipeline isn’t five steps - it’s five signals
Every CSS property you change picks how many stages you fire - and choosing “composite only” over “all three” is often the difference between an animation refreshing 60 times per second and one that updates only once every half a second.
Knowing about the cascade, the render tree, reflow, repaint, and layer promotion isn’t for showing off. It’s so that when you see a stuttering animation, you have a map in your head: “this goes through layout, I can force it to go only through composite by swapping left for transform”. Or: “this loop is doing a forced layout every iteration, I can split it into two passes”. The pipeline doesn’t change with the framework or the Chrome version, so once you’ve internalized it, it stays useful for a long time.
Comments
Loading comments…