How browsers parse HTML

HTML · Expert · 14 min

Knowing what the parser does explains phantom elements, broken templates and why innerHTML sometimes fails.

HTML parsing is precisely specified, including how to handle broken markup, so every modern browser builds the same tree from the same bytes. That spec has a few behaviours every expert should know.

Tokenizer then tree builder. Bytes are decoded (using the BOM, HTTP charset, or a <meta charset> found in the first 1024 bytes), tokenized into tags and text, and fed to a tree builder with insertion modes such as "in head", "in body" and "in table".

Implied tags. The parser inserts <html>, <head>, <body> and <tbody> if missing, and many end tags are optional: </p>, </li>, </td>, </option> close automatically when a sibling starts.

Error recovery. Misnested formatting tags such as <b><i></b></i> are repaired by the adoption agency algorithm. Content that can't go inside a table is foster-parented: it's moved before the table. That's why text typed between <tr> elements appears above the table.

Raw text elements. Inside <script> and <style> the parser looks only for the matching end tag, so a string containing </script> inside inline JavaScript ends the script early. Escape it as <\/script>.

Scripts pause parsing. A classic script must run before parsing continues, because it might call document.write. The preload scanner looks ahead and starts fetching images and scripts anyway, which is why markup-declared resources load faster than ones injected by JavaScript.

Finally, innerHTML uses the same parser in a fragment context, but <script> elements inserted that way never execute.

Example

<!-- What you write: -->
<table>
  <tr><td>A</td></tr>
  Oops, stray text
</table>
<ul><li>One<li>Two</ul>
<p>Para <b>bold <i>both</b> italic?</i></p>

<!-- What the DOM contains (as devtools shows it):
"Oops, stray text"            <- foster-parented before the table
<table><tbody><tr><td>A</td></tr></tbody></table>
<ul><li>One</li><li>Two</li></ul>
<p>Para <b>bold <i>both</i></b><i> italic?</i></p>
-->
<script>
  // const s = "</script>";   would end this script element early
  const s = "<\/script>";     // safe
</script>

Remember

  • The parser fixes broken markup deterministically, per the spec
  • Missing html/head/body/tbody tags are inserted for you
  • Stray content in tables is foster-parented before the table
  • </script> inside an inline script ends it: escape it

Watch out for

  • Assuming the DOM matches your source; inspect the parsed tree in devtools when selectors don't match.

Check yourself

  1. Text placed directly between two <tr> elements ends up where in the DOM?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: html.spec.whatwg.org/multipage/parsing.html