ParsingGENERALSIMPLIFIEDFRAMEWORK-SPECIFIC

Tree Construction

Tokens become a DOM through insertion modes and a stack of open elements — which is why the tree the browser built is frequently not the markup you wrote.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Why is the DOM in the Elements panel different from the HTML I sent, and what rules produced the difference?

The user intent

A developer writes markup expecting the structure they typed. A user needs a document whose headings, landmarks and controls nest sensibly enough to navigate.

The obvious build

Every tag I write becomes an element, nested exactly as I nested them. If I forget a closing tag the browser adds it where I meant it.

Why it breaks

It adds it where the *spec* meant it. <p>one<div>two</div></p> produces a p containing "one", then a sibling div, then an empty p — because a div start tag closes an open p, and the later </p> has nothing to close so it opens a new one.

How it breaks in a real browser
  • It adds it where the *spec* meant it. <p>one<div>two</div></p> produces a p containing "one", then a sibling div, then an empty p — because a div start tag closes an open p, and the later </p> has nothing to close so it opens a new one.
  • It invents elements you never wrote. A document with no <html>, <head> or <body> gets all three, and content before the first <body>-only element is quietly moved.
  • Content in the wrong place inside a table is *foster parented*: <table><div>x</div></table> puts the div immediately before the table in the DOM. Your CSS selector matching a descendant of the table never matches.
  • Misnested formatting elements are repaired by the adoption agency algorithm, which duplicates elements. <b>1<i>2</b>3</i> produces a b containing an i, plus a second i — two i elements from one tag.
  • A framework that server-renders one string and then hydrates against the parsed DOM compares its expected tree with the tree the parser actually built. When the recovery rules moved something, you get a hydration mismatch and no obvious cause (Hydration Mismatch).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The tree builder holds a stack of open elements. A start tag pushes; an end tag pops back to the matching element if it is in scope, and is otherwise ignored.
  • It runs in an insertion mode — "before html", "in head", "in body", "in table", "in select" and a dozen more. The mode decides how each token is handled, and tokens can switch it.
  • Some elements have implied end tags. <li>, <p>, <td>, <option> and friends are closed automatically when a token arrives that cannot legally follow them. This is a rule about the token, not about your intent.
  • Scope limits how far an end tag may pop. </p> closes a p only if one is in button scope; a </div> cannot reach past a <td>. Scoping is why some stray end tags are ignored entirely.
  • A list of active formatting elements tracks b, i, em, a and similar. When they are misnested across block boundaries, the adoption agency algorithm reconstructs them — by cloning.
  • Foreign content — SVG and MathML — has its own rules, including case-sensitive attribute fixups (viewBox, not viewbox) and a different set of tokenizer interactions.
  • <script> is executed during tree construction, not after it. The parser suspends, the script runs against a partial DOM, and whatever the script mutates is what parsing resumes on top of (Why a Script Tag Stops the Parser).

What this makes the browser do

And which of it is avoidable.

  • Maintaining the stack, the active formatting list, the insertion mode and several element pointers (the head element, the form element) for the length of the document.
  • Re-running the adoption agency algorithm each time formatting elements are misnested, which allocates additional DOM nodes you did not author.
  • Foster parenting moves nodes, which invalidates style and layout for the region around the table (Style Invalidation).
  • Every appended node is a candidate for style resolution and layout. On a long document the browser interleaves parsing with rendering rather than waiting for the end (Streaming HTML).
  • Avoidable: nearly all of the repair work. Well-formed markup takes the fast path through the same algorithm with no cloning and no moving.

A stack, a mode and a pointer

Tree construction is a small machine with a lot of rules. The stack of open elements records what is currently open; the insertion mode records where in the document you conceptually are; a handful of pointers remember the head element and the current form. Each token is dispatched according to the mode, and the response is usually "insert an element" but is sometimes "close something first", "move this elsewhere" or "ignore this token entirely".

The value of holding that picture is that every surprising DOM stops being arbitrary. An end tag that seemed to do nothing was an end tag for an element not in scope. A paragraph that closed itself met a start tag that cannot appear inside a paragraph. The tree is not the parser being lenient; it is the parser being extremely specific.

Token stream into a tree
dispatchpush / popreconstructmisplaced in table<script> tokenmutates mid-parseinsertcloneinsert before tableToken streamInsertion mode (in head / in body / in table …)Stack of open elementsActive formatting elementsFoster parentingScript execution (suspends the parser)DOM tree
UserLLMAgentToolDataDecisionHumanGuardrail

The tree you wrote and the tree you got

The fastest way to internalise this is to look at pairs. Each row below is legal input that produces a document — no errors, no warnings, no complaints — and a structure that differs from the obvious reading. None of it is browser-specific: every mainstream engine builds exactly these trees.

The last two rows are the ones that reach production. Foster parenting breaks CSS selectors and event delegation roots, and the dropped nested form breaks submission in a way that looks like a backend routing bug (Submission: Method, Encoding and Doing It Once).

MarkupWhat it looks like it saysWhat the parser buildsRule
<p>one<div>two</div></p>A div inside a paragraph<p>one</p><div>two</div><p></p>A div start tag closes an open p; the trailing </p> has no match and opens an empty one
<ul><li>a<li>b</ul>Malformed, probably broken<ul><li>a</li><li>b</li></ul>Implied end tags: an li start tag closes the open li. This one is correct and intentional
<table><div>x</div></table>A div inside a table<div>x</div><table></table>Foster parenting: content that cannot live in table context is inserted immediately before the table
<b>1<i>2</b>3</i>Two overlapping formatting elements<b>1<i>2</i></b><i>3</i>The adoption agency algorithm clones the i so the tree stays properly nested — two i elements from one tag
<div />nextA self-closed div, then text<div>next</div>The self-closing flag is ignored on non-void HTML elements; the div stays open
<form><form><input></form></form>Nested forms<form><input></form>The form element pointer is already set, so the inner form start tag is ignored — the input belongs to the outer form
<table><tr><td>xMissing tbody and all end tags<table><tbody><tr><td>x</td></tr></tbody></table>The tbody is generated; all the end tags are implied at EOF. This is why tbody appears in the Elements panel you never wrote
Confirming it yourself
1const d = new DOMParser().parseFromString(
2 '<p>one<div>two</div></p>',
3 'text/html',
4)
5d.body.innerHTML
6// "<p>one</p><div>two</div><p></p>"
7
8// Fragment parsing depends on the context element:
9const div = document.createElement('div')
10div.innerHTML = '<tr><td>x</td></tr>'
11div.innerHTML // "x" — tr and td dropped, "in body" mode has no place for them
12
13const tbody = document.createElement('tbody')
14tbody.innerHTML = '<tr><td>x</td></tr>'
15tbody.innerHTML // "<tr><td>x</td></tr>" — parsed in table context

DOMParser runs the same algorithm the network parser runs, which makes it the cheapest way to answer "what tree does this markup build" without a server. The innerHTML pair is the fragment-parsing rule made visible.

Scripts run in the middle of this

Tree construction is not a phase that completes before JavaScript starts. A <script> token suspends the parser, the script executes against whatever tree exists at that moment, and parsing resumes on the result. That is the whole reason a classic script sees only the DOM above it, and the reason document.write was possible at all (Why a Script Tag Stops the Parser).

The practical consequence is that "the DOM" during parsing is a moving target. Code that queries for an element that appears later in the document finds nothing, and the fix is not a longer timeout — it is to run after parsing, which is exactly what defer and type="module" provide (`defer`, `async` and `type="module"`).

Reading the DOM during parsing
Classic script in the head
<head>
  <script>
    // Runs before the body token has even been seen.
    document.querySelector('#app').dataset.ready = '1'
    // TypeError: null
  </script>
</head>
<body><div id="app"></div></body>
Deferred, ordered, after parsing
<head>
  <script defer src="/boot.js"></script>
</head>
<body><div id="app"></div></body>

<!-- boot.js -->
// The full tree exists; DOMContentLoaded has not fired yet,
// so this still runs before anything that listens for it.
document.querySelector('#app').dataset.ready = '1'

The problem is not that the first script is "too early" in a vague sense. It is that tree construction has not reached the #app token, so the node does not exist in the DOM the script is querying. defer moves execution to a point in the specified ordering where the tree is complete, without giving up execution order relative to other deferred scripts.

How to build it

Most important first.

  • Write markup that survives the recovery rules unchanged — close what you open, nest block content outside phrasing content, and put table content in <tbody>, <tr>, <td>. Then the tree you get is the tree you wrote.
  • Check the Elements panel, not the source, when structure surprises you. It is the only place the built tree is visible (A Mental Model of the Devtools).
  • Do not put a <div> inside a <p>, a <form> inside a <form>, or an interactive element inside another one. Each has a specified repair, and none of the repairs is what you wanted (Document Structure and Reading Order).
  • When a framework warns about invalid nesting, treat it as a parser warning rather than a lint preference: it is predicting a tree that will not match what it renders on the client (Hydration Mismatch).
  • Build DOM with DOM APIs when structure matters. document.createElement and append bypass tree construction entirely and put the node exactly where you said (What a Mutation Costs).
  • Remember that innerHTML parses with a *context element* through a fragment-parsing algorithm. Setting <tr> markup on a <div> drops the tags; setting it on a <tbody> keeps them.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • The accessibility tree is computed from *this* tree, not from your markup. If foster parenting moved a caption out of its table, assistive technology reports the relationship the DOM has, not the one you intended (The Accessibility Tree).
  • Auto-closing changes document structure, and document structure is how screen-reader users navigate. A heading that ended up as a sibling of the section it was meant to title breaks the outline a rotor or landmark list presents.
  • A dropped nested <form> takes its aria-describedby targets and label associations with it — controls end up labelled by nothing, which is a name computation failure rather than a visual one (Errors People Can Actually Perceive).
  • Table repair is particularly costly for accessibility: header association (<th scope>, headers) depends on cell position in the built tree. Content relocated out of a table has no header association at all.
  • None of this is fixable with ARIA. ARIA modifies roles and properties on nodes that exist; it cannot restore a parent-child relationship the parser did not create (Semantics Before ARIA).

What can go wrong

Failure modes
  • Foster parenting silently relocating content out of a table, so a descendant selector, an event delegation root or a query all miss it (Event Delegation).
  • Auto-closed <p> producing empty paragraphs that pick up margin, creating spacing nobody can find a rule for.
  • Nested <form> elements: the inner one is dropped entirely, so its controls submit with the outer form and the wrong action (Submission: Method, Encoding and Doing It Once).
  • A script inserted mid-document mutating the tree above the parser's insertion point while parsing continues below it, producing an interleaving that depends on when the script's network response arrived.
  • The mitigation failing: a "sanitiser" that inspects a string with a regular expression before assigning it. The string it inspected and the tree the parser builds are not the same object, and the difference is exploitable (Sanitization and Trusted HTML).
What can arrive out of order
  • A script that runs mid-parse sees only the DOM above its own position. Its own mutations and the parser's continued insertions interleave in an order determined by when the script's bytes arrived, not by document order.
  • A MutationObserver registered during parsing receives records for nodes the parser appends. Whether it sees a subtree as one record or several depends on chunk boundaries, which are a network artefact.
  • A late <base href> changes URL resolution for elements already speculatively fetched, so which URL was actually requested depends on parse timing (The Preload Scanner).
Security
  • Sanitising HTML by string inspection is unsafe precisely because tree construction is not string manipulation. The parser normalises, moves and reinterprets; a filter that saw a harmless string can produce a dangerous tree (Sanitization and Trusted HTML).
  • Mutation XSS is exactly this gap: markup that survives a sanitiser, is serialised, then reparsed into a different tree that contains a script-bearing node. The round trip through innerHTML is where it happens.
  • The browser enforces one useful thing here: <script> elements inserted via innerHTML do not execute. This stops the naive attack and stops nothing else, since onerror, onload and javascript: URLs remain (Cross-Site Scripting).
  • Use the browser's own sanitiser API where available, or a maintained library that parses rather than pattern-matches. Availability varies by browser, so check before relying on it.
Misreads
  • "The DOM is the HTML." The DOM is the result of running a specified repair algorithm over the HTML. They match only when the markup gave the algorithm nothing to do.
  • "The browser fixed my markup, so it is fine." It applied a rule. The rule is deterministic and cross-browser, and it may have moved your content somewhere your CSS and your JavaScript do not look.
  • "innerHTML and a document parse produce the same tree." Fragment parsing depends on the context element, so the same string on a <div> and on a <tbody> yields different nodes.
  • "Hydration mismatches are a framework bug." They are usually the framework correctly noticing that the parser built a tree different from the one the server described (Hydration Mismatch).

Measuring it, and what changes in the field

How you would see this
  • The Elements panel is the built tree. Comparing it against the response body in the Network panel is the fastest diagnosis of any structure surprise.
  • document.body.innerHTML in the console serialises the tree back to markup, which makes normalisation and relocation visible as a diff against what you sent.
  • A framework's hydration mismatch warning names the node where the server's expected tree and the parsed tree diverged — usually the exact point a recovery rule fired (Hydration).
  • An HTML validator reports the parse errors that trigger repair. It will not tell you what the repaired tree looks like; only the browser does that.
Slow device, slow network, large data, old tab
  • On a large generated document — a long table, an export view — repair work scales with the number of malformed constructs, and each foster-parented node also invalidates style around the table.
  • On a slow network the document arrives over many chunks, and any script that runs mid-parse sees a different amount of DOM depending on how much has arrived. Code that reads the DOM during parsing is timing-dependent (Streaming HTML).
  • In a client-rendered app the initial tree is near-empty and almost all structure comes from DOM APIs, so these rules matter mostly at the server-rendering boundary (Client-Side Rendering).
  • When markup passes through a CMS, a rich-text editor or an email template, the string has usually been parsed and reserialised several times already, and each round trip can move things.
What this costs
  • Writing markup that never triggers repair is more disciplined than relying on recovery, and the payoff is invisible when everything works. It shows up as the class of bug you never have.
  • Building structure with DOM APIs is precise and more verbose than a template string, and it moves the cost from parse time to script time (What a Mutation Costs).
  • Validating markup in CI catches parse errors early and produces noise about rules that do not affect the built tree. Filter it to the constructs that cause relocation, not to every warning.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALInsertion modes, the stack of open elements, foster parenting and the adoption agency algorithm are specified in the HTML Standard and implemented identically across Blink, Gecko and WebKit — they are covered by a shared conformance test suite, so cross-browser differences here are bugs rather than variation.
  • SIMPLIFIEDThe full algorithm has more than twenty insertion modes plus separate rules for foreign content, template contents and fragment parsing. This lesson covers the handful that produce the differences developers actually meet; it is not a specification summary.
  • FRAMEWORK-SPECIFICHow a mismatch between the parsed tree and a framework's expected tree is surfaced differs sharply: React logs a hydration error and may re-render the subtree on the client, while compiled frameworks such as Svelte and Solid detect less and may leave the mismatch in place silently.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Compilers & Programming Languages — shift-reduce parsing and the explicit stack. HTML's stack of open elements is the same idea with the grammar replaced by a table of per-mode rules, and with recovery promoted from an error path to the main path.
  • Software Design — the tree builder is a state machine with several mutable side registers (an insertion mode, a stack, two element pointers). It is worth reading as a case study in how much implicit state a "simple" transformation can require.