Under the Hood
Rendering

Parsing HTML into the DOM

The critical rendering path starts by turning a raw byte stream into the DOM tree through a two-phase, fully specified parser that never throws on broken markup — and that same parser can be stopped cold by a single script tag, which is exactly where async and defer come from.

Parsing HTML into the DOM

Open the network tab, load any page, and watch the HTML arrive as a stream of bytes — not a document, not a tree, just octets landing in order. Somewhere between that byte stream and the document object your JavaScript queries with document.querySelector, a tree gets built. Most explanations of this step wave their hands and say "the browser parses the HTML into the DOM," as if that were one operation. It's actually two, each with its own machinery, and the details of both explain two things every web developer has memorized without necessarily understanding: why a page with mismatched tags still renders fine, and why a stray <script> tag in the wrong place can make your whole page hang.

The frame for this lesson: HTML parsing is two phases — tokenizing turns bytes into a stream of tokens, and tree construction turns that token stream into the DOM tree — and both phases are driven by state machines precise enough that the parser never errors out, no matter how broken the markup is. Once that's in place, the rest follows: why parsing is incremental, why scripts block it, and what async and defer actually opt out of.

Phase one: tokenizing

The tokenizer (the HTML spec calls it the "tokenization stage") reads the incoming byte stream — already decoded to characters using the document's character encoding — one character at a time, and turns it into a sequence of tokens: a start tag, an end tag, a text run, a comment, a doctype. It does this with a state machine that has dozens of named states — "data state," "tag open state," "tag name state," "attribute name state," and so on — where each character read can change which state the tokenizer is in.

Type <p class="lead">Hi</p> at the tokenizer and it walks: sitting in the data state, it sees < and switches to tag open state; the following p moves it to tag name state, where it accumulates letters until whitespace kicks it into before attribute name state; it accumulates class, sees =, moves through attribute value states collecting lead, then > closes the tag and emits a start tag token: p, with one attribute, class="lead". Then it's back in the data state accumulating Hi as a character token, until </p> produces an end tag token for p.

The output of this whole phase is just a flat stream of tokens — no nesting, no tree, no notion of "this is inside that." That structure only appears in phase two.

Phase two: tree construction

Tree construction consumes the token stream and builds the actual DOM, one token at a time, using its own state machine — the insertion mode — plus a data structure called the stack of open elements. The stack tracks which elements are currently "open" (their start tag has been seen, their end tag hasn't yet), which is how the tree constructor knows what the current node's parent should be: whatever's on top of the stack.

A start tag token generally means "create an element node, append it as a child of whatever's on top of the stack, then push it onto the stack." An end tag token generally means "pop elements off the stack until the matching one comes off." The insertion mode — states with names like "before html," "in head," "in body," "in table," "in cell" — tracks the broader parsing context, because the same token can mean different things in different modes: a stray <td> behaves very differently depending on whether the parser thinks it's inside a <table>.

This is also where the DOM's parent/child/sibling structure actually comes from — it is a direct byproduct of stack pushes and pops, not something the tokenizer knows anything about.

Error tolerance: a recovery algorithm, not a lucky accident

Here's the detail that makes HTML parsing genuinely unusual: it is specified to never throw a fatal error. Feed it any sequence of bytes at all — truncated tags, elements closed out of order, tags that were never opened — and the algorithm still produces a tree. This isn't the parser being sloppy; the WHATWG HTML spec defines exact recovery behavior for every malformed case, so that any two spec-compliant browsers parse the same broken markup into the same tree.

Three recovery behaviors account for almost everything you've seen "just work":

Auto-closing unclosed tags. Reach the end of the token stream with elements still on the stack of open elements, and the parser closes them all, in order, as if matching end tags had appeared. A page missing its final </body></html> still gets both nodes.

Implying missing elements. Certain elements are only legal in certain contexts, and the parser will insert the context for you. Write a bare <table><tr><td>cell</td></tr></table> with no <tbody>, and tree construction inserts one anyway, because the "in table" insertion mode knows a <tr> can't be a direct child of <table>. Inspect that markup in devtools and you will find a tbody node you never typed.

Foster parenting. Put content where the insertion mode says it can't legally go — text or a block element directly inside a <table>, not inside a cell — and the parser doesn't discard it or error out. It "foster parents" the misplaced node: pulls it out and reinserts it as a sibling immediately before the table in the tree, rather than as the table's child.

<!-- What you wrote -->
<table>
  <tr><td>One</td>
  <tr><td>Two</td>
</table>

<!-- The tree the parser actually builds -->
<table>
  <tbody>
    <tr><td>One</td></tr>
    <tr><td>Two</td></tr>
  </tbody>
</table>

Notice two separate recoveries stacked in that one snippet: the missing </td> and </tr> end tags are supplied by auto-closing (an open <tr> or <td> closes automatically when the next <tr> or the closing </table> is reached), and the missing <tbody> is implied around both rows.

Contrast this with XML — and XHTML served as application/xhtml+xml, which uses an XML parser instead of the HTML one. XML parsing is famously draconian: a single mismatched tag or unescaped & is a fatal, unrecoverable error, and the spec requires the parser to stop and show the user an error page instead of a document. HTML's recovery algorithm is the reason a typo in your markup gives you a slightly odd-looking page instead of a blank screen with a parse error — that leniency was a deliberate, later-formalized design choice, not the absence of one.

Parsing is incremental — the tree grows as bytes arrive

The tokenizer and tree constructor don't wait for the whole document to download before starting. They run incrementally, consuming bytes as the network delivers them and growing the DOM tree in place. This is why you can watch a page's structure appear progressively in devtools while a slow connection is still loading the page, and it's the reason the rest of the critical rendering path doesn't have to wait for the last byte of HTML to show something — style and layout work for the DOM built so far can begin before the document finishes arriving.

Scripts block the parser

Now the piece that trips people up. When the tokenizer hands the tree constructor a <script> start tag, the parser doesn't just insert a <script> node and move on — it stops. It pauses tokenizing and tree construction entirely, fetches the script if it's external, executes it, and only then resumes parsing where it left off.

The reason is specific and mechanical: a running script can call document.write(), and document.write injects new characters directly into the tokenizer's input stream — the exact stream the parser is in the middle of consuming. The parser cannot safely keep reading ahead in the original byte stream while a script might be about to splice new content into it, so the spec makes script execution a synchronous, blocking step in tree construction. A script can also just read the DOM built so far (document.getElementById, for example) and get a wrong or incomplete answer if the parser kept running underneath it — blocking keeps what the script sees consistent with what's actually been parsed.

async and defer: two different opt-outs

Both attributes let an external script download in parallel with parsing instead of blocking it, but they differ in when the script is allowed to run:

<!-- Blocking (default): parser stops here, waits for download + execution -->
<script src="analytics.js"></script>

<!-- async: downloads in parallel, executes the instant it's ready —
     which can be before parsing finishes, interrupting it, and in
     whatever order each script happens to finish downloading -->
<script src="analytics.js" async></script>

<!-- defer: downloads in parallel, but execution waits until parsing
     is completely finished, and multiple deferred scripts run in
     the document order they appear in -->
<script src="app.js" defer></script>

async downloads off the parser's critical path, but as soon as the download completes, it still interrupts parsing to execute immediately — and if you have several async scripts, they run in whichever order they happen to finish downloading, not the order they appear in the document. That makes async a good fit for independent scripts that don't depend on the DOM or on each other, like analytics snippets.

defer also downloads in parallel, but execution is held until tree construction has completely finished, and deferred scripts always run in document order, right before the DOMContentLoaded event. That makes defer the right default for application scripts that need a complete DOM and a predictable execution order relative to each other.

Neither attribute changes how the document's own parsing proceeds while the script downloads — that part is never blocked. What they change is whether and when the downloaded script gets to interrupt.

The preload scanner: peeking ahead while the parser is stuck

If the main parser can be stalled for hundreds of milliseconds waiting on a blocking script, why doesn't every image and stylesheet below it also sit unrequested for that whole time? Because the browser runs a second, much lighter parser alongside the real one: the preload scanner (sometimes called the "speculative" or "lookahead" parser). It scans ahead through the raw, not-yet-processed bytes, looking specifically for resource references — <img src>, <link rel="stylesheet">, <script src> — and kicks off their network requests immediately, even while the main tokenizer and tree constructor are frozen waiting on an earlier blocking script.

The preload scanner doesn't build any tree and doesn't run any scripts; it only reads ahead far enough to find things worth fetching early, so that by the time the real parser reaches them, the bytes are already on their way. It's a major reason blocking scripts hurt less in practice than the naive model suggests — the fetching of downstream resources isn't actually gated by the parser being stuck, only their insertion into the tree is.

The document.write hazard, briefly

document.write is the reason scripts must block the parser in the first place, and it earns its bad reputation because of exactly that mechanism: call it from a script that itself got fetched asynchronously (say, an async script), and the injected markup can land in the tokenizer's input at an unpredictable moment relative to the rest of the document, sometimes clobbering content or arriving too late to matter. Modern browsers actively restrict or ignore document.write calls coming from scripts loaded with async or over slow connections precisely because the interaction between "script mutates the input stream" and "parser is supposed to be reading that stream in order" stops being well-defined.

Where this goes next

Everything above happens on the HTML side of the pipeline the flagship lesson laid out. In parallel — and on its own timeline — the CSS bytes are going through an analogous but distinctly stricter journey: tokenized, then parsed into the CSSOM. Unlike the DOM, that structure gets to block the first paint outright, and the reasons why turn into a set of concrete loading optimizations. Parsing CSS into the CSSOM picks up there.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. What are the two phases of HTML parsing, and what does each one take as input and produce as output?
  2. What is the stack of open elements tracking, and how does it determine where the next node gets attached in the tree?
  3. Why is HTML parsing specified to never throw a fatal error, and how does that contrast with how an XML parser handles a malformed document?
  4. Walk through a table missing its <tbody> tag: which recovery behavior inserts it, and why does the insertion mode need to know it's 'in table'?
  5. Why does hitting a plain <script> tag stop tree construction specifically, rather than just being skipped and inserted as a node like any other element?
  6. Contrast async and defer: what do they both let happen in parallel, and what's the one thing that differs between them?
  7. What does the preload scanner actually do while the main parser is blocked on a script, and what does it deliberately not do?