From HTML bytes to pixels
The previous page ended with bytes arriving through a data pipe. When those bytes are an HTML document, they go into Blink's HTML parser, become a DOM tree, and then pass through style, layout, paint and compositing before anything reaches the screen. We step through the parser on any HTML we type, check every tree against this browser's own parser, and then read real Chrome traces that show exactly which stages a DOM change triggers.
Browser track: ← 1 · What fetch() really sends · 2 · From HTML bytes to pixels · next: 3 · From a click to our listener →
How this was checked: the parser model on this page builds the same tree as Chrome 141's own parser on 8,929 distinct inputs (61 hand-picked and 8,868 distinct random tag soups, from four seeded batches). The pipeline numbers are real trace events from headless Chromium 141 loading and changing a small test page; the traces are in the folder's data/ directory. Blink source excerpts are from upstream Chromium (October 2026), trimmed with // ….
Bytes become characters, and characters become tokens
The bytes are first decoded to characters (our page declared UTF-8). Then HTMLDocumentParser::PumpTokenizer runs a loop: ask the tokenizer for the next token, hand it to the tree builder, repeat. The loop has a budget. After a number of tokens or a time limit it yields, returning to the event loop so the page can render what it has so far. That is how a long page shows content before the parser reaches the end.
The tokenizer itself is the state machine from the HTML standard, written as a big switch. In the data state, < moves to the tag-open state; a letter there begins a start tag; / begins an end tag; ! leads to comments and DOCTYPEs; anything else means the < was just text.
Chrome's trace of our test page recorded one ParseHTML event with parsed_bytes: 450 and parsed_tokens: 46. Our model's tokenizer, given the same 450 bytes, emits 46 tokens before end-of-file. Text between two tags is buffered into a single character token, which is why the counts line up.
Type HTML, step through the parser, check it against this browser
Each step processes one token. We see the tokenizer states it passed through, the insertion mode, the stack of open elements, the list of active formatting elements, the rule that fired, and the DOM so far in the html5lib tree format browsers use for their parser tests. The Check button parses the same text with this browser's own DOMParser and compares the trees.
What it is. Our tokenizer and tree builder for the HTML standard's head and body insertion modes, including implied tags, list items, reconstruction and the adoption agency algorithm.
Checked against. Real Chrome's parser on 8,929 distinct inputs (61 hand-picked plus 9,000 random, of which 8,868 are distinct) with identical trees (verify/compare-with-chrome.js), and live: Check against this browser compares with the parser you are using right now.
Not covered. Tables, forms, select, template, framesets, ruby, SVG and MathML, the full named-entity table, parse-error reporting, and scripts that run during parsing. The lab says so instead of guessing.
Token
Insertion mode
Stack of open elements (bottom → top)
Active formatting elements
Rules that fired
DOM so far (html5lib format)
Tokens become a DOM, and broken HTML is repaired the same way everywhere
The tree builder is a second state machine. Its state is the insertion mode (initial, before html, in head, in body and more), and it keeps the stack of open elements, whose top is where new nodes go. Most rules are about missing or misplaced tags. A block element such as <div> first closes any open <p>; a new <li> closes the previous one; text with no <html>, <head> or <body> gets all three implied.
Formatting elements such as <b> and <i> also live on a second list, the active formatting elements. When they are closed in the wrong order, the adoption agency algorithm moves nodes around so the result is still a tree. When text appears after a block closed them, they are reconstructed (reopened). Try "Misnested b and i" and "b around a p" in the lab.
Each element the tree builder creates is a C++ object (HTMLParagraphElement, HTMLDivElement, …) on Blink's garbage-collected heap. JavaScript never touches it directly. document.querySelector returns a V8 wrapper object, and its properties call generated binding code; that is why the method for offsetHeight below is named offsetHeightForBinding.
Style, layout, pre-paint, paint: one ordered pipeline
A DOM is not pixels. Blink runs each document through a fixed lifecycle, enforced by a state machine in document_lifecycle.h. Style computes each element's final CSS values. Layout computes boxes: positions and sizes. Pre-paint builds property trees (transforms, clips, effects). Paint records display lists, meaning drawing commands rather than pixels. Commit hands the result to the compositor thread, worker threads raster it into pixels, and the GPU process draws the frame.
Changing the DOM or a style does not run these stages at once. It marks things dirty and calls ScheduleLayoutTreeUpdate, which asks for a visual update. The stages run later, in the next frame's BeginMainFrame, and only from the first dirty stage onward.
Seven real traces: which stages each change ran
We loaded a small test page in headless Chrome 141 and recorded a trace around each change below. Every number is a real trace event: the count of each stage and, for the main-thread stages, how long it took. The test page has a .card div and a #mover div with will-change: transform.
What it is. Real trace events from headless Chrome 141 on Linux for seven changes to one small test page.
Checked against. They are the real traces (data/trace-*.json, which open in DevTools or Perfetto) and they reproduce: re-recording gives the same stage counts.
Not covered. Timing on other machines: this ran headless with software rendering, so durations are only indicative; the stage counts are the point. Pages with real content take much longer per stage.
| t | thread | event | took | details |
|---|
Why reading offsetHeight after a write runs layout right now
A layout read such as offsetHeight has to return a correct number, so it cannot wait for the next frame. The binding calls UpdateStyleAndLayoutForNode, which runs style and layout immediately, inside our script. One read is fine. But a loop that alternates writing a style and reading a layout value forces a full style-and-layout pass on every iteration. This is layout thrashing.
Layers, raster on worker threads, and why transform is cheap
Paint does not produce pixels. It records drawing commands into display lists, grouped by layer. In our load trace there are two Paint events: one for the document and one for DIV id='mover', because will-change: transform gave that element its own composited layer.
After Commit, the compositor thread owns a copy of the layer tree. RasterTask events run on ThreadPoolForegroundWorker threads, turning display lists into pixel tiles, and the frame is drawn. A transform on a layer that already exists changes only how the compositor places that layer. In the trace that change produced no Layout, no Paint and no RasterTask: the same pixels, moved.
This is also why the main thread matters so much. Everything before commit (our JavaScript, style, layout and paint) runs on CrRendererMain, one task at a time, as the event-loop page showed. A long task delays the next frame's lifecycle. Compositor-only changes, such as scrolling or a transform on an existing layer, can keep moving while the main thread is busy.
Predict, then check
See it on our own machine
| What to see | How |
|---|---|
| The pipeline per frame | DevTools → Performance → record. Purple Recalculate Style and Layout, and green Paint blocks are the same events as our traces. Our raw traces are in data/trace-*.json; load them with "Load profile". |
| Forced layouts | In a Performance recording, Layout blocks inside a script are flagged "Forced reflow". |
| What gets repainted | DevTools → More tools → Rendering → Paint flashing. Change a color, then a transform, and compare. |
| Composited layers | DevTools → More tools → Layers. An element with will-change: transform appears as its own layer. |
| The parsed DOM | DevTools → Elements shows the tree after parsing, with implied tags filled in. Compare with "View page source". |
| The full trace | Open a trace in Perfetto to see every thread, including compositor and raster workers. |
Exercises
- In the lab, type
<a href=1>x<div>y<a href=2>z. Step through it and explain why the second<a>runs the adoption agency, then press Check. - Write
<b><b><b><b>x</b></b></b></b><p>yand find the step where the "Noah's Ark" rule evicts an entry. How many<b>wrapy? - Record a Performance trace of a page that animates
left, then the same animation withtransform: translateX(). Count Layout and Paint events per frame in each. - Rewrite this loop so it forces at most one layout:
for (const el of items) el.style.height = el.offsetWidth / 2 + "px"; - Look at
data/trace-load.jsonin Perfetto. Which thread ranRasterTask, and how many tiles were rastered?
Exam-style questions, with model answers
Five questions in the style of a web systems or browser internals exam. Write our answer on paper first, then open the model answer and mark ourselves honestly. The marks follow the usual pattern of university theory exams; they are not copied from any real paper.
<p>One<b>two<p>three</b>four, and name the rule responsible for each surprising part.| <html> | <head> | <body> | <p> | "One" | <b> | "two" | <p> | <b> | "three" | "four"
Implied tags: no DOCTYPE, html, head or body, so all are created. The second <p> closes the first, because a <p> start tag closes an open p in button scope; that pops the b too. Reconstruction: b is still in the active formatting list, so when "three" arrives it is reopened inside the second p. </b> runs the adoption agency; b is the current node with no block inside it, so it is simply popped, and "four" goes into the p. (Produced by our model and identical to Chrome's parse.)
Marking: 2 for the tree, 1 each for implied tags, the p closing, reconstruction, and the end of b.
<b>1<p>2</b>3</p>. What are the formatting element and the furthest block, and what is the final tree?When </b> arrives, the stack is html, body, b, p and the active formatting list holds b. The formatting element is that b. The furthest block is the topmost special element above it on the stack: the p. The common ancestor is body.
The algorithm moves the p out of b and appends it to body, creates a new b, moves p's children ("2") into the new b, and appends the new b to p. The old b is removed from both lists, and the new b is put on the stack after p. On the second outer iteration the new b has no block above it, so it is popped. "3" then goes into p.
| <html> | <head> | <body> | <b> | "1" | <p> | <b> | "2" | "3"
Marking: 1 for the formatting element, 1 for the furthest block, 2 for the restructuring steps, 1 for the final tree.
color does not trigger layout, why changing width does, and what a transform on an element with will-change: transform skips. Cite trace evidence.Every change invalidates from the earliest stage whose output it affects. color changes computed style and the drawing commands, but no box sizes or positions: the trace shows UpdateLayoutTree and Paint but no Layout. width changes box geometry, so layout is dirty and everything after it reruns: one Layout event with dirtyObjects: 4 of 23. A transform on an element that already has its own composited layer changes only a node in the transform property tree; the compositor re-positions the existing layer. The trace shows no Layout, no Paint and no RasterTask, only PrePaint, Layerize and Commit.
Marking: 1 per property with the right stage, 1 for citing the trace counts.
for (let i = 0; i < 100; i++) { const w = items[i].offsetWidth; items[i].style.width = (w + 10) + "px"; } How many forced layouts happen inside the loop? Rewrite it to need none, and say when layout then runs.99 forced layouts. The first read finds layout clean and needs no work. Every later read comes after a write in the previous iteration, so each of the remaining 99 reads runs UpdateStyleAndLayout. A final layout also runs in the next frame.
const widths = items.map(el => el.offsetWidth); // reads: layout is clean
items.forEach((el, i) => { el.style.width = (widths[i] + 10) + "px"; }); // writes only
Now no read follows a write, so nothing is forced inside the script. Style and layout run once, in the next frame's lifecycle update.
Marking: 2 for 99 with the reason, 2 for a read-all-then-write-all rewrite, 1 for "one layout at the next frame".
<a href="x">, starting in the data state, and say what is emitted.Data → (<) tag open → (a) tag name → (space) before attribute name → (h) attribute name → (=) before attribute value → (") attribute value (double-quoted) → (") after attribute value (quoted) → (>) emit the start tag token and go back to the data state.
One token is emitted: a start tag named a with one attribute, href="x", not self-closing. In Blink these are the kTagOpenState, kTagNameState and attribute states of HTMLTokenizer::NextTokenImpl.
Marking: 3 for the states in order, 1 for the emitted token.
Everything this page cites, by depth
The numbered markers in the text open the relevant reading right where we need it. Here is all of it in one place: Start here for a first pass, Go deeper for the full treatment, and Primary sources for the specification and test suite the code implements.
Original files and data
- html/parser/html_document_parser.cc
- html/parser/html_tokenizer.cc
- html/parser/html_tree_builder.cc
- dom/document_lifecycle.h
- dom/document.cc
- frame/local_frame_view.cc
- html/html_element.cc
- Our traces, test page and capture script (data/)
Blink code is used under its BSD-style license, quoted for study. Paths are relative to third_party/blink/renderer/core/.