Skip to content
AgileLogics
7 min read

How the HTML Parser Works

Level 1 showed that browsers repair broken markup. The repairs follow a precise algorithm in the HTML standard, and knowing it explains many "impossible" bugs.

Two stages

  1. The tokenizer reads characters and produces tokens: start tags, end tags, text, comments, the doctype. It's a state machine — for example, inside <script> or <style> it treats everything as raw text until the matching end tag, and inside <textarea> and <title> it reads text but still decodes character references.
  2. The tree builder turns tokens into DOM nodes. It keeps a stack of open elements and an insertion mode ("in head", "in body", "in table", ...) that decides what each token means.

Implied elements and auto-closing

DOMParser runs the same parser from JavaScript, so you can watch it work:

<script>
  const parse = html => new DOMParser().parseFromString(html, "text/html");

  console.log(parse("Hello").documentElement.outerHTML);
  console.log(parse("<p>One<p>Two<ul><li>A<li>B</ul>").body.innerHTML);
  console.log(parse("<table><tr><td>1</td></tr></table>").body.innerHTML);
</script>
<html><head></head><body>Hello</body></html>
<p>One</p><p>Two</p><ul><li>A</li><li>B</li></ul>
<table><tbody><tr><td>1</td></tr></tbody></table>
  • html, head, and body are created even if you never write them.
  • A new <p> closes the open one; so does <ul>. A new <li> closes the previous li.
  • Rows are always put inside a tbody — which is why a CSS selector like table > tr never matches in an HTML page.

Foster parenting

Content that isn't allowed in a table is moved before it:

<script>
  const doc = new DOMParser().parseFromString(
    "<table><tr><td>cell</td></tr><p>stray text</p></table>", "text/html");
  console.log(doc.body.innerHTML);
</script>
<p>stray text</p><table><tbody><tr><td>cell</td></tr></tbody></table>

A template language that accidentally emits a div or text between table rows produces exactly this — content that jumps above the table.

The adoption agency algorithm

Misnested formatting elements (b, i, a, em, strong, ...) are repaired by cloning them, as you saw in Level 1. Links can't nest, so a second <a> closes the first:

<script>
  const doc = new DOMParser().parseFromString(
    '<a href="/one">one <a href="/two">two</a></a>', "text/html");
  console.log(doc.body.innerHTML);
  console.log(doc.querySelectorAll("a").length, "links");
</script>
<a href="/one">one </a><a href="/two">two</a>
2 links

Raw text and script injection

Inside <script>, the tokenizer looks only for </script. So a string containing that sequence ends the script early — a classic bug when embedding JSON from the server:

<script>
  const doc = new DOMParser().parseFromString(
    '<script>const note = "</' + 'script><b>oops</b>";</' + 'script>', "text/html");
  console.log(doc.head.innerHTML);
  console.log(doc.body.innerHTML);
</script>
<script>const note = "</script>
<b>oops</b>";

When you output JSON inside a script tag, escape < as < (most server frameworks' JSON encoders can do this for you).

Why this matters

  • The DOM you style and script may differ from the HTML you wrote. When a selector or querySelector "doesn't work", inspect the real DOM.
  • innerHTML uses the same parser, with the same repairs.
  • Sanitizers must parse exactly like browsers, or attackers exploit the differences (Sanitizing User Content, later in this level).

Related developer tools