Skip to content

6.1.2 — The Rendering Pipeline

The renderer process has a stream of HTML text and an empty window. Between those two facts sits a fixed sequence of steps that never changes order, and knowing the order is what separates guessing at a performance problem from fixing it.

The sequence is: parse the HTML into a tree, parse the CSS into rules, combine them so every element has a final set of styles, work out where everything goes, work out what colour every pixel should be, and hand the result to the graphics card. Six steps, and every one of them can be the thing that is slow.

HTML bytes① parseCSS bytes② parse③ StyleDOM + CSSOM④ Layoutsize + position⑤ Paintdraw commands⑥ Compositelayers to screenchange `width` — everything from ④ re-runschange `color` — ⑤ and ⑥ onlychange `transform` — ⑥ only,off the main thread
The pipeline runs left to right, but a change does not always start at the left. Which step a change enters at is the single biggest lever in frontend performance.

1. HTML into a DOM, and why HTML never has a syntax error

The parser reads the character stream and produces two things at once: a stream of tokens (a start tag, an attribute, some text, an end tag) and, from those tokens, a tree of nodes. That tree is the DOM — the Document Object Model, the live object graph your JavaScript later manipulates.

The single most surprising property of this parser is that it cannot fail. Feed a JSON parser a missing brace and it throws. Feed the HTML parser this:

html
<p>First<p>Second<b>bold<i>both</b>italic</i>

and it produces a perfectly well-formed tree. It closes the first <p> when it sees the second, because the HTML specification says a <p> cannot contain a <p>. It notices that </b> closes an element with <i> still open, and it reconstructs the <i> inside the following content so that the tree stays properly nested. There is a named algorithm for this — the "adoption agency algorithm" — and it exists purely to define, precisely, what a browser must do with broken markup.

This is deliberate and it is not laziness. Early browsers all guessed differently at broken HTML, so a page that worked in one browser rendered as garbage in another. HTML5 fixed the problem not by rejecting bad markup — half the web would have gone blank — but by specifying the recovery exactly, so every browser now produces the identical tree from the identical broken input.

The consequence for you: the DOM you get is not always the HTML you wrote. Inspect the elements panel rather than the source when something nests oddly. Chapter 6.2.1 covers the content-model rules that drive this, including the one that catches everybody — why a <div> inside a <p> silently becomes a <div> after the <p>.

2. What stops the parser, and what does not

The parser walks the document in order, and two kinds of tag change what happens next.

A classic <script> tag stops everything. When the parser meets <script src="...">, it must stop parsing, fetch the file, and execute it before continuing. It has no choice: the script might call document.write() and insert more HTML right there. Nothing after that tag exists in the DOM yet, which is why a script in the <head> that touches document.body finds null.

A stylesheet does not stop parsing, but it does stop rendering. The parser keeps building the DOM while CSS downloads, but the browser will not paint anything until the CSS has arrived. This is called render-blocking and the reason is user-facing: paint first and the user sees unstyled text jump into position a moment later, which is worse than a slightly longer blank screen.

There is a nastier interaction between the two. A script cannot run until all stylesheets before it have loaded, because the script may ask for a computed style and the answer would be wrong otherwise. So a slow stylesheet blocks the script, and the blocked script blocks the parser, and one slow CSS file has stalled the entire document.

The preload scanner is the mitigation. While the main parser is blocked, a second lightweight scanner races ahead through the raw bytes looking only for URLs — scripts, stylesheets, images — and starts fetching them. It builds no tree and runs no code; it exists purely to keep the network busy while the parser is stuck. This is why an image URL written in HTML starts downloading earlier than the identical URL set from JavaScript, and it is the mechanism behind several of the loading rules in Chapter 6.7.

Chapter 6.3.2 covers async, defer and type="module", which are the tools for controlling all of this deliberately.

3. CSSOM, then style

CSS is parsed into its own tree of rules, the CSSOM. Unlike HTML, CSS parsing does discard what it cannot understand: an unknown property or an invalid value is dropped and the rest of the rule survives. That forgiving-by-discarding behaviour is what makes progressive enhancement possible, and Chapter 6.2.2 shows how to exploit it deliberately.

The style step then walks the DOM and, for every element, works out the final computed value of every CSS property. Every property — not just the ones you set. An element has a value for color whether you wrote one or not, arriving through inheritance or through the browser's default stylesheet.

Two facts about this step matter in practice.

Selectors are matched right to left. For .sidebar ul li a, the engine does not find .sidebar and walk down. It collects every <a> and then walks up each one asking "is the parent an li, is its parent a ul, is any ancestor .sidebar?" Right-to-left lets it reject a candidate at the first failed step, which is far cheaper than exploring every descendant of every match. The practical read: the rightmost part of a selector should be the most specific part. A selector ending in * or in a bare tag name makes the engine test every element on the page.

Style is usually not your bottleneck, but it can be. On a page with a few thousand elements and a few thousand rules, recalculating all styles is single-digit milliseconds. It becomes a problem when something invalidates the whole document repeatedly — toggling a class on <html>, or a CSS custom property change high in the tree that many descendants read (Chapter 6.2.3).

4. Layout: the step that costs the most

Style says an element is display: flex with width: 50%. Layout turns that into numbers: this box starts at x=112, y=840, is 486 pixels wide and 32 tall. It is also called reflow, and the two words mean the same thing.

The work is genuinely hard, because sizes depend on each other in both directions. A child's width often depends on its parent's width, which sounds like a simple top-down pass — but a parent sized by its content depends on its children's sizes, which is bottom-up. Text makes it worse: how tall a paragraph is depends on how many lines it wraps to, which depends on how wide it is, which may depend on how tall its container is. The engine resolves this in multiple passes, and a deeply nested flexible layout can require several.

The number that matters: layout cost scales with the number of boxes affected, not with the size of the change. Setting one element's width to one pixel more can require re-laying-out its parent, its siblings and every descendant. On a list of ten thousand rows that is a visible freeze from a one-property change.

Forced synchronous layout, the classic frontend bug

The browser is lazy on purpose. It batches your DOM writes and does layout once, at the end, before painting. That laziness is defeated the moment you read a value that depends on layout.

js
const cards = document.querySelectorAll('.product-card');

// WRONG — this loop forces a full layout on every single iteration.
for (const card of cards) {
  // (1) A read that needs current geometry.
  const height = card.offsetHeight;              
  // (2) A write that invalidates that geometry.
  card.style.height = `${height + 8}px`;         
}

Line (1) asks for offsetHeight. The browser cannot answer from stale data, so it runs layout immediately — synchronously, in the middle of your loop. Line (2) then changes a size, which marks the layout dirty again. Next iteration, line (1) forces another full layout. With 200 cards that is 200 complete layout passes instead of one, and the profiler shows a solid block of purple labelled "Layout" with a warning triangle on it.

The fix is to separate the phases. Read everything, then write everything:

js
const cards = [...document.querySelectorAll('.product-card')];

// (1) READ phase — one layout at the first read, then all answers come from it.
const heights = cards.map(card => card.offsetHeight);

// (2) WRITE phase — nothing here reads geometry, so nothing forces a layout.
cards.forEach((card, i) => {
  card.style.height = `${heights[i] + 8}px`;
});
// One layout, at the end, before the next paint.

Line (1) triggers exactly one layout: the first offsetHeight forces it, and every subsequent read in the same loop is answered from the now-clean layout. Line (2) only writes, so the invalidation happens once and the browser resolves it at the normal time. Same output, one layout instead of two hundred.

The properties that force layout when read are worth memorising as a family rather than a list: anything geometric. offsetTop/offsetLeft/offsetWidth/offsetHeight, clientTop/clientLeft/clientWidth/clientHeight, scrollTop/scrollWidth, getBoundingClientRect(), getComputedStyle(), and focus(). If the answer is a number of pixels, reading it forces layout.

5. Paint and layers

Paint turns the laid-out boxes into a list of drawing commands: fill this rectangle, draw this text with this font, draw this border, clip to this rounded corner. It does not produce pixels yet; it produces instructions.

Painting happens into layers. A layer is a surface that can be drawn once and then reused. The browser promotes an element to its own layer when it has reason to believe the element will move or change independently of the rest — a position: fixed header, a <video>, an element with a 3D transform, an element with will-change: transform.

Layers are the trick that makes smooth animation possible, and they are not free. Each layer costs memory: a full-screen layer at 1920×1080 with four bytes per pixel is about 8 MB, and on a mobile device with a handful of promoted elements that adds up fast. Promoting everything "for performance" is a classic own-goal — a hundred layers means a hundred textures to hold and a hundred surfaces to combine, and the page gets slower.

6. Composite, and the animations that skip the main thread

Compositing is the final step: the compositor thread takes the layers, applies each one's position, scale, rotation and opacity, and asks the GPU process to draw them to the screen in the right order.

Here is the payoff, and it is the most useful single fact on this page. The compositor thread can do this without the main thread's involvement at all. If an animation only changes properties the compositor already owns, then even a main thread that is completely blocked by JavaScript cannot make the animation stutter.

Exactly two families of property qualify:

  • transformtranslate, scale, rotate, skew
  • opacity

Both are pure transformations of an already-painted surface. Nothing about the element's size, position in the document flow, or painted content changes; only how the existing texture is placed and blended.

css
/* SLOW — `left` is a layout property. Every frame: layout, paint, composite,
   all on the main thread, sixty times a second. */
.toast-bad {
  position: absolute;
  left: -320px;
  transition: left 250ms ease-out;   
}
.toast-bad.visible { left: 24px; }   

/* FAST — `transform` is a compositor property. The layer is painted once
   and then just repositioned. Zero main-thread work per frame. */
.toast-good {
  position: absolute;
  left: 24px;
  transform: translateX(-344px);
  transition: transform 250ms ease-out;   
}
.toast-good.visible { transform: translateX(0); }   

The two rules produce a visually identical slide. The first runs layout and paint on every frame on the main thread; the second paints once and then moves a texture. On a fast desktop with an idle page you will not see the difference. On a mid-range phone, or on any page that is also running JavaScript, the first one drops frames and the second one does not.

This is why the advice "animate only transform and opacity" exists, and it is worth being able to say why rather than repeating it. It is not that those properties are magically fast. It is that they are the only ones whose change does not invalidate anything the main thread owns.

Telling the browser in advance

The compositor needs the element on its own layer before the animation starts, or the first frame stalls while it is promoted. will-change is how you say so:

css
.drawer { will-change: transform; }

Use it sparingly and remove it when the animation is over. It is a hint that costs memory for as long as it is set. Setting will-change: transform on every card in a list is a memory leak with a CSS syntax. The correct pattern is to add it on the interaction that precedes the animation — a :hover on the parent, or a class added in JavaScript — and drop it on transitionend.

Two properties that cut work out of the pipeline entirely

contain and content-visibility let you tell the browser about isolation it cannot infer.

css
.feed-item {
  /* (1) This element's internals never affect anything outside it. */
  contain: layout paint;

  /* (2) If it is off screen, skip its style, layout and paint completely. */
  content-visibility: auto;

  /* (3) The size to assume while it is skipped, so scrollbars stay sane. */
  contain-intrinsic-size: auto 280px;
}

Line (1) promises that a change inside this element cannot change the size or position of anything outside it, so the browser can scope a relayout to this subtree instead of the document. Line (2) is the strong version: for elements outside the viewport the browser skips the rendering work altogether, which on a long feed can turn a multi-second first layout into a fast one. Line (3) matters more than it looks — without a size hint, a skipped element reports as zero-height, the scrollbar jumps as you scroll, and the experience is worse than the slow version. content-visibility: auto without contain-intrinsic-size is a bug, not an optimisation.

7. The frame budget, honestly

At 60 Hz, a frame is due every 16.7 ms. That figure is quoted constantly, and it is worth being precise about what it contains, because the budget available to you is smaller than it sounds.

Inside those 16.7 ms the browser must run pending input handlers, run requestAnimationFrame callbacks, recalculate style, do layout, paint, composite, and hand off to the GPU. The compositor and raster work happens on other threads, but the first several steps are all main thread. A widely used working figure is that your JavaScript gets about 8–10 ms if the rest of the pipeline is to fit.

Miss it and the frame is simply not produced. The previous frame stays on screen for another 16.7 ms, and the user sees a stutter. Miss it consistently while scrolling and the page feels broken in a way users describe as "cheap" without being able to say why.

On a 120 Hz display the budget halves to 8.3 ms. This is not a hypothetical: most current phones ship high-refresh screens, so the device with the slowest processor is also the one asking for frames most often.

The measurement rule that follows from this whole page: open the performance profiler, record an interaction, and read the colours. Purple is style and layout, green is paint and composite, yellow is your JavaScript. A wall of yellow means your code. A wall of purple after a small change means either layout thrashing or a property you thought was cheap and is not. You do not have to guess, and guessing is what the pipeline knowledge is here to replace.

What the interviewer will push on

"Walk me through what happens between HTML arriving and pixels appearing." Parse to DOM, parse CSS to CSSOM, style, layout, paint, composite. The tell is knowing that layout and paint can be skipped for some changes, and naming transform/opacity as the pair that reaches only the compositor.

"Why is CSS render-blocking but scripts parser-blocking?" CSS blocks rendering because painting unstyled content and restyling it is worse for the user than waiting. A classic script blocks parsing because it might document.write() into the stream. Then volunteer the interaction people miss: a script cannot run until preceding stylesheets have loaded, so a slow CSS file stalls the whole document through the script.

"What is layout thrashing and how do you find it?" Alternating reads of geometric properties with writes, forcing a synchronous layout per iteration. You find it in the profiler as repeated layout entries inside one task, often flagged with a warning. The fix is batching reads before writes. The common wrong answer is "use requestAnimationFrame" — that helps with timing, but a read-write-read-write loop thrashes just as hard inside a frame callback.

"Why animate transform instead of left?" left is a layout property, so every frame re-runs layout, paint and composite on the main thread. transform is handled by the compositor on an already-painted layer, so a blocked main thread cannot stall it. Say the mechanism, not the rule.

"Is will-change a free performance win?" No — it forces a layer, and a layer costs memory (roughly 8 MB for a full-screen surface). Applied broadly it makes things slower. Add it just before the animation, remove it after.

"Our long product list janks on first load. What would you try?" Reduce the number of boxes laid out: virtualise the list (Chapter 6.9), or apply content-visibility: auto with contain-intrinsic-size so off-screen items skip style, layout and paint. Mentioning the intrinsic size unprompted is the signal that you have shipped this rather than read about it.

One thing to volunteer: point out that the HTML parser cannot fail, and that this is a specification decision rather than sloppiness — HTML5 defined the exact recovery from broken markup so that all browsers produce the same tree. It explains a whole class of "why is the DOM not what I wrote" bugs, and it shows you have read about the platform rather than only used it.

Recall

  • The pipeline is fixed: parse HTML to DOM, parse CSS to CSSOM, style, layout, paint, composite. Which step a change enters at is the main performance lever.
  • The HTML parser cannot fail. HTML5 specifies recovery from broken markup exactly, so every browser builds the same tree — which is why the DOM sometimes differs from your source.
  • CSS is render-blocking; a classic script is parser-blocking; and a script waits for preceding stylesheets, so slow CSS stalls the document. The preload scanner races ahead for URLs while the parser is stuck.
  • Selectors match right to left, so the rightmost part should be the most specific.
  • Layout (reflow) turns styles into numbers and costs in proportion to the boxes affected. Reading a geometric property (offsetHeight, getBoundingClientRect, getComputedStyle) forces it synchronously — batch reads before writes or you get one layout per loop iteration.
  • Only transform and opacity reach the compositor alone, so only they animate smoothly while the main thread is busy. They are not magic; they are the changes that invalidate nothing the main thread owns.
  • will-change promotes a layer early and costs memory (~8 MB full screen) — add it before, remove it after. content-visibility: auto skips off-screen work and requires contain-intrinsic-size or the scrollbar jumps.
  • 16.7 ms per frame at 60 Hz, about 8–10 ms of it for your JavaScript, halved on a 120 Hz screen. Miss it and the frame is simply not produced.

Self-test: Why does <p>a<p>b produce two paragraphs rather than an error? · Why can one slow stylesheet delay the entire document? · What makes offsetHeight expensive inside a loop? · Why does a busy main thread not stutter a transform animation? · What breaks if you use content-visibility: auto alone?

Next: 6.2.1 goes back to the first step of the pipeline and looks at the input itself — what the HTML you write actually means to the parser, why some nestings are silently rewritten, and why the element you choose changes what a screen reader and a search engine can do with the page.