Skip to main content

Parsing pipeline - from bytes to DOM and CSSOM

Parsing pipeline is the browser's multi-stage process that converts raw HTML and CSS bytes into the DOM and CSSOM trees that JavaScript and the rendering engine can actually use.

Theory

TL;DR

  • Pipeline runs in 4 stages: Bytes → Characters → Tokens → Tree
  • HTML and CSS follow the same stages but produce different results: a live, mutable DOM vs a read-only CSSOM
  • Parsing is incremental - the browser starts rendering before the full document arrives
  • A <script> tag pauses the HTML parser; CSS in <head> blocks rendering until CSSOM is ready
  • defer downloads in parallel and runs after parsing; async runs as soon as the file lands

Quick example

javascript
// Browser receives HTML as raw bytes // 3C 68 31 3E 48 65 6C 6C 6F 3C 2F 68 31 3E // Stage 1: Bytes → Characters (UTF-8 decode) // "<h1>Hello</h1>" // Stage 2: Characters → Tokens // StartTag(h1), Character("Hello"), EndTag(h1) // Stage 3: Tokens → DOM node document.querySelector('h1').textContent; // "Hello" // The node exists even before the rest of the document finishes loading

Parsing does not wait for the whole file. The browser processes chunks of roughly 14KB and starts building the tree right away.

The four stages

Stage 1: Bytes to characters. Before reading a single character, the browser needs to know the encoding. It checks in this order: BOM (Byte Order Mark) in the file, the HTTP Content-Type header, the <meta charset> tag, then auto-detection. First match wins. This is why <meta charset="utf-8"> must appear in the first 1024 bytes. If the browser has already started parsing with the wrong encoding, it restarts from scratch.

Stage 2: Tokenization. The tokenizer is a state machine defined in the HTML spec. It reads the character stream and emits tokens: StartTag, EndTag, Character, Comment. One thing that surprises people: <tr> without a <tbody> does not cause an error. The tokenizer just emits the tokens; the tree constructor fixes the structure afterward.

Stage 3: Tree construction. The tree constructor consumes tokens and builds DOM nodes. It handles malformed HTML without stopping: it auto-closes open tags, inserts missing elements like <tbody>, and reparents nodes to match HTML5 rules. The DOM is live throughout this process. JavaScript running during parsing can already see nodes that have been created.

Stage 4: CSS takes a parallel path. While HTML parsing builds the DOM, CSS files get their own tokenizer and tree constructor. The CSS tokenizer emits selector, property, and value tokens. The tree constructor builds the CSSOM. Unlike HTML, invalid CSS is simply skipped without stopping the parser. But rendering is blocked until both DOM and CSSOM are ready. That is the reason CSS belongs in <head>.

Preload scanner

Here is something most developers do not think about. The main HTML parser stops when it hits a <script> tag. But the browser does not just sit there waiting. A secondary thread called the preload scanner keeps reading ahead through the raw HTML and queues downloads for <link rel="stylesheet">, <script src>, <img src>, and <link rel="preload"> resources.

html
<script src="slow-script.js"></script> <!-- Parser is blocked here --> <img src="hero.jpg"> <!-- Preload scanner already started fetching hero.jpg -->

This is one of the most impactful browser optimizations in existence. document.write() breaks it entirely, because it can inject new characters into the stream after the scanner has already moved past that point.

Key difference: DOM vs CSSOM

The DOM is mutable. JavaScript reads and writes it constantly. The CSSOM is read-only from JavaScript's perspective. You can read computed styles with getComputedStyle(), but you cannot write to the CSSOM directly. Setting element.style.color = 'red' does not touch the CSSOM. It creates an inline style on the DOM node, which then wins over CSSOM rules because inline styles carry the highest specificity.

When to care about this

  • CSS in <body> instead of <head> delays the first paint because CSSOM is not ready when the DOM is
  • <script> without async or defer blocks both parsing and rendering
  • A <meta charset> placed after the first 1024 bytes can force the browser to restart parsing
  • Large inline style blocks inflate the DOM and slow tree construction
  • document.write() disables the preload scanner and kills parallel downloads

Common mistakes

Mistake 1: Querying the DOM before the node exists

javascript
// WRONG: script runs before <h1> is parsed <script> console.log(document.querySelector('h1')); // null </script> <h1>Hello</h1> // RIGHT: wait for DOMContentLoaded document.addEventListener('DOMContentLoaded', () => { console.log(document.querySelector('h1')); // "Hello" });

The parser stops at <script>, runs the script, then continues. If <h1> is below the script tag, the node does not exist yet when the script runs.

Mistake 2: CSS in the body

html
<!-- WRONG --> <body> <h1>Title</h1> <link rel="stylesheet" href="styles.css"> <!-- CSSOM not ready --> <p>Content</p> </body> <!-- RIGHT --> <head> <link rel="stylesheet" href="styles.css"> </head>

The browser will not paint anything until both DOM and CSSOM exist. Putting CSS in the body means CSSOM might not be ready when the parser reaches visible content above it.

Mistake 3: Trying to write to getComputedStyle()

javascript
// WRONG: CSSOM is read-only const style = getComputedStyle(element); style.color = 'red'; // does nothing // RIGHT element.style.color = 'red'; // inline style element.classList.add('highlight'); // apply rule from CSSOM

Mistake 4: Using async for scripts that depend on each other

html
<!-- WRONG: execution order not guaranteed --> <script src="jquery.js" async></script> <script src="app.js" async></script> <!-- app.js might run before jquery.js --> <!-- RIGHT: defer preserves order --> <script src="jquery.js" defer></script> <script src="app.js" defer></script>

async runs the script as soon as it downloads, ignoring document order. defer waits until parsing is complete, then runs scripts in the order they appear. For app code that uses the DOM, defer is almost always the right choice.

Mistake 5: Expecting encoding to be guessed correctly

html
<!-- WRONG: charset meta tag too late --> <html> <head> <title>Page</title> <!-- ~800 bytes of other content here --> <meta charset="utf-8"> <!-- browser may have already guessed wrong --> </head>

The charset declaration must appear in the first 1024 bytes. If it does not, the browser guesses. If it guesses wrong, you get garbled characters and a full parser restart.

Real-world usage

  • React SSR: renderToString() sends HTML bytes from Node.js. The browser parses them through this same pipeline. Hydration happens after DOM is built, so useEffect runs after paint
  • Next.js: Streams HTML with renderToNodeStream(), letting the browser start parsing before the full response arrives. Incremental parsing in practice
  • Chrome DevTools: The "Parse HTML" metric in the Performance tab measures tokenization plus tree construction. High values usually mean a very large document or malformed markup the parser has to repair
  • Webpack/bundlers: Control script loading by injecting async, defer, or type="module" attributes on <script> tags
  • Googlebot: Parses HTML and runs JavaScript to build the DOM, then indexes it. Client-side-only rendering can hide content from the crawler if JavaScript takes too long

Follow-up questions

Q: Why does the browser parse HTML incrementally instead of waiting for the full file?


A: Performance. If the browser waited for a 5MB HTML file to finish downloading before parsing, users would see a blank screen for seconds. Incremental parsing lets the browser render above-the-fold content while the rest of the document is still in transit.

Q: What happens if <meta charset> conflicts with the HTTP Content-Type header?


A: The HTTP header wins. The browser processes the charset from Content-Type: text/html; charset=utf-8 before it reads a single HTML byte. The <meta> tag only matters when the header is missing.

Q: Why can't JavaScript modify the CSSOM directly?


A: The CSSOM is indexed by selector for fast lookups, not for mutations. Allowing direct writes would also break the cascade in unpredictable ways. Instead, you modify the DOM (inline styles or class changes), and the browser recalculates computed styles against the CSSOM.

Q: How does defer differ from async in terms of the parsing pipeline?


A: Both download the script in parallel without blocking the parser. async runs the script as soon as it downloads, potentially interrupting parsing mid-way. defer waits until parsing is complete, then runs scripts in the order they appear in the document.

Q: (Senior) You have a 50MB HTML document with thousands of inline styles. How do you reduce parse time?


A: First, avoid sending 50MB of HTML. Split the page and lazy-load sections. If you must handle it: move inline styles to CSS classes, because inline styles inflate both the DOM and trigger CSSOM recalculation on every style change. Use Transfer-Encoding: chunked or React's renderToNodeStream() so the browser can start parsing before the full response arrives. Then profile in Chrome DevTools. Often the real bottleneck is not parsing but layout and paint.

Examples

Encoding detection order

html
<!DOCTYPE html> <!-- Browser checks encoding in this order: --> <!-- 1. BOM in the file (highest priority) --> <!-- 2. HTTP Content-Type: text/html; charset=utf-8 --> <!-- 3. This meta tag (must be in first 1024 bytes) --> <meta charset="utf-8"> <title>Page</title> <!-- 4. Browser auto-detection (last resort) -->

Charset detection happens before a single character is tokenized. If the first three signals conflict, the earlier one wins and the parser may restart entirely.

Script loading: blocking vs defer vs async

html
<!-- Blocks parsing - parser stops until script downloads and runs --> <script src="app.js"></script> <!-- Downloads in parallel, runs AFTER parsing finishes, in order --> <script src="jquery.js" defer></script> <script src="app.js" defer></script> <!-- Downloads in parallel, runs as soon as ready, order NOT guaranteed --> <script src="analytics.js" async></script> <!-- Breaks preload scanner for the entire page --> <script>document.write('<script src="bad.js"><\/script>');</script>

For any script that touches the DOM or depends on another script, defer is the correct attribute. async is for isolated scripts like analytics trackers that do not care about page state.

FOUC caused by CSS loading order

javascript
// Server sends this HTML (Node.js / React SSR) const html = ` <!DOCTYPE html> <html> <head> <!-- If styles.css is slow, the browser renders unstyled HTML first (FOUC) --> <link rel="stylesheet" href="styles.css"> </head> <body> <h1>Content</h1> </body> </html>`; // The browser builds the DOM incrementally. // Rendering waits for CSSOM. // Slow styles.css = blank screen, not unstyled content. // FOUC happens when CSS is in <body> or loaded via JS after DOM is built. // Fix: inline critical CSS in <head> to eliminate the blocking request const criticalCss = `h1 { font-size: 2rem; }`; const optimized = ` <head> <style>${criticalCss}</style> <link rel="stylesheet" href="styles.css" media="print" onload="this.media='all'"> </head>`;

FOUC (Flash of Unstyled Content) happens when the DOM is ready but CSSOM is not. Inlining critical styles removes the blocking network request for above-the-fold content.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?