HTML Parsing & the DOM Tree
HTML is not XML. The browser runs a forgiving, stateful parser defined by the WHATWG HTML Standard. It tokenizes bytes into start tags, end tags, text, comments, and DOCTYPEs, then builds a DOM tree with detailed rules for mis-nested markup, scripts, tables, and foreign content (SVG/MathML).
Understanding parsing helps you debug “why DevTools shows a different tree than my source,” use innerHTML safely, sanitize untrusted markup, and validate documents. This guide is practical: enough algorithm detail to reason about bugs, without reprinting the entire spec.
Bytes → character decoding → tokenizer → tree builder → DOM. Encoding detection uses BOM, HTTP Content-Type, and early meta charset. After the tree exists, scripts may block and re-enter the parser.
| Stage | Input | Output |
|---|---|---|
| Decode | Bytes | Code points |
| Tokenize | Code points | Tokens |
| Tree construct | Tokens | DOM nodes |
| Scripting | DOM + external JS | Possible document.write reentry |
| 1 | HTTP response (text/html; charset=utf-8) |
| 2 | │ |
| 3 | ▼ |
| 4 | Encoding sniffer / decoder |
| 5 | │ |
| 6 | ▼ |
| 7 | Tokenizer (state machine) |
| 8 | │ |
| 9 | ▼ |
| 10 | Tree construction (insertion modes) |
| 11 | │ |
| 12 | ▼ |
| 13 | Document + Element + Text nodes |
note
The tokenizer is a state machine. Important states include data, RCDATA (title/textarea), RAWTEXT (script/style historically nuanced), plaintext, tag open, attribute states, and character reference states.
| Token type | Meaning |
|---|---|
| DOCTYPE | Document mode switch |
| Start tag | Element open + attributes |
| End tag | Element close |
| Comment | Comment node |
| Character | Text (may be batched) |
| EOF | End of stream |
| 1 | <!-- Character references resolved in many states --> |
| 2 | <title>Fish & Chips</title> |
| 3 | |
| 4 | <!-- Script data does not process entities the same way as body text --> |
| 5 | <script> |
| 6 | // const x = '<'; // is NOT converted to < inside classic script text |
| 7 | </script> |
info
Tree construction maintains a stack of open elements and an insertion mode. Start tags create elements and push them; end tags pop; some tags imply closing others (p before block, li before li, etc.).
| 1 | <!-- Author --> |
| 2 | <p>One<div>Two</div>Three |
| 3 | |
| 4 | <!-- Typical result --> |
| 5 | <p>One</p><div>Two</div>Three |
| 6 | |
| 7 | <!-- List re-parenting --> |
| 8 | <ul><li>A<li>B</ul> |
| 9 | <!-- → two li children, implied end tags --> |
best practice
Table content models are strict. When the parser finds nodes that cannot live where they were written inside a table context, it foster parents them — typically inserting the node as a sibling before the table (or into a foster parent element). This is why random <div> tags inside <table> “jump out” in DevTools.
| 1 | <table> |
| 2 | <div class="note">This text is not a table cell.</div> |
| 3 | <tr> |
| 4 | <td>Cell</td> |
| 5 | </tr> |
| 6 | </table> |
| 7 | |
| 8 | <!-- DevTools often shows roughly: |
| 9 | <div class="note">...</div> |
| 10 | <table> |
| 11 | <tbody><tr><td>Cell</td></tr></tbody> |
| 12 | </table> |
| 13 | --> |
| 1 | <!-- Also surprising: text nodes directly in table --> |
| 2 | <table> |
| 3 | Hello |
| 4 | <tr><td>X</td></tr> |
| 5 | </table> |
warning
pro tip
Classic <script> elements switch the tokenizer into a script data state. The content is not parsed as HTML tags (except the script end tag algorithm). External scripts with src still create a script element; fetching/execution interacts with defer/async/module.
| Kind | Parser behavior |
|---|---|
| Inline classic | Script data until </script>; then execute (legacy sync rules) |
| src + default | Fetch, blocking depending on position/attrs |
| defer | Order-preserving, runs after document parse |
| async | Runs when ready, unordered |
| type=module | Deferred module graph; CORS for cross-origin |
| document.write | Can inject tokens into the open stream (avoid) |
| 1 | <script> |
| 2 | // To include </script> in a string, break it up: |
| 3 | const s = '</' + 'script>'; |
| 4 | </script> |
| 5 | |
| 6 | <script type="module" src="/app.js"></script> |
| 1 | // document.write during parse injects into the stream |
| 2 | // After parse, document.write clears/overwrites in many cases — do not use it |
| 3 | document.write('<p>bad idea after load</p>'); |
danger
<noscript> content depends on scripting enabled/disabled and on whether it appears in head or body. In head with scripting disabled, it can contain link/style/meta; in body it contains flow content. Authors use it for fallback UI — keep fallbacks minimal.
| 1 | <head> |
| 2 | <noscript> |
| 3 | <link rel="stylesheet" href="/no-js.css" /> |
| 4 | </noscript> |
| 5 | </head> |
| 6 | <body> |
| 7 | <noscript> |
| 8 | <p>This app requires JavaScript for interactive features.</p> |
| 9 | </noscript> |
| 10 | </body> |
note
innerHTML and outerHTML invoke HTML fragment parsing (with context element rules). Creating nodes via createElement, textContent, and append does not parse markup — safer for untrusted strings.
| API | Parses HTML? | Scripts execute? |
|---|---|---|
| textContent / createTextNode | No | N/A |
| innerHTML | Yes (fragment) | script tags inserted via innerHTML do not run |
| insertAdjacentHTML | Yes | Same — scripts do not run |
| document.write | Yes (stream) | Can run / reenter |
| Range.createContextualFragment | Yes | Scripts generally do not run |
| 1 | // Safe text |
| 2 | el.textContent = '<img src=x onerror=alert(1)>'; |
| 3 | |
| 4 | // Parses HTML — event handlers in markup can still be dangerous |
| 5 | // depending on browser + how nodes are used; treat as untrusted sink |
| 6 | el.innerHTML = userHtml; |
| 7 | |
| 8 | // Build safely |
| 9 | const li = document.createElement('li'); |
| 10 | li.textContent = userName; |
| 11 | list.append(li); |
warning
DOMParser.parseFromString parses a full document or SVG/XML depending on MIME type. For HTML, use text/html. Errors in XML mode produce parsererror documents.
| 1 | const parser = new DOMParser(); |
| 2 | const doc = parser.parseFromString( |
| 3 | '<!DOCTYPE html><title>t</title><p>Hello <b>world</b></p>', |
| 4 | 'text/html' |
| 5 | ); |
| 6 | console.log(doc.body.textContent); // Hello world |
| 7 | |
| 8 | const xml = parser.parseFromString('<a><b></a></b>', 'application/xml'); |
| 9 | console.log(xml.querySelector('parsererror') ? 'invalid xml' : 'ok'); |
info
Untrusted HTML must be sanitized before insertion. Allowlists beat denylists. Keep SVG/MathML disabled unless you understand their attack surface. Combine sanitization with CSP.
| 1 | import DOMPurify from 'dompurify'; |
| 2 | |
| 3 | const clean = DOMPurify.sanitize(userHtml, { |
| 4 | USE_PROFILES: { html: true }, |
| 5 | FORBID_TAGS: ['style'], |
| 6 | FORBID_ATTR: ['style'], |
| 7 | }); |
| 8 | container.innerHTML = clean; |
| Risk | Example |
|---|---|
| Event handlers | onerror=, onclick= |
| javascript: URLs | href="javascript:..." |
| SVG script | <svg><script> |
| Mutable nested browsing | iframe srcdoc |
| CSS exfiltration | style with urls / attrs |
| mXSS | Mutation XSS via parser + sanitize order |
danger
DOCTYPE selection sets document.compatMode. Quirks mode changes CSS box sizing for some elements, table layouts, and other legacy behaviors. Always ship <!DOCTYPE html>.
| 1 | if (document.compatMode === 'BackCompat') { |
| 2 | console.warn('Quirks mode — check DOCTYPE'); |
| 3 | } |
best practice
Use the Nu Html Checker as part of CI or pre-release checks. Focus on errors that imply wrong trees: stray end tags, unclosed elements, illegal nesting, duplicate IDs.
| 1 | 1. Save representative templates (home, article, form, table-heavy) |
| 2 | 2. Validate at https://validator.w3.org/nu/ |
| 3 | 3. Or: vnu --format text dist/**/*.html |
| 4 | 4. Triage: |
| 5 | - Illegal nesting → fix source structure |
| 6 | - Obsolete attributes → replace with CSS/ARIA |
| 7 | - Warnings about sectioning → review outline |
| 8 | 5. Re-check DOM in DevTools Elements panel |
| 9 | 6. Add regression fixtures for parser surprises (tables, p/div) |
| 1 | # Example CI idea |
| 2 | npx vnu-jar --filterfile .vnuignore public/**/*.html |
pro tip
| 1 | Symptom: styles apply to wrong element |
| 2 | → Compare source vs Elements panel; look for implied closes |
| 3 | |
| 4 | Symptom: table layout broken |
| 5 | → Search for div/p inside table; foster parenting |
| 6 | |
| 7 | Symptom: extra wrapper or missing wrapper |
| 8 | → Check p around blocks; a around interactive; form around form |
| 9 | |
| 10 | Symptom: script string broken at </script> |
| 11 | → Split string or use module/JSON technique |
| 12 | |
| 13 | Symptom: XSS after “escaping” |
| 14 | → Wrong context encoder; switch to textContent/sanitizer |
| 1 | [ ] DOCTYPE present → standards mode |
| 2 | [ ] Source nesting matches intended DOM |
| 3 | [ ] No div/text foster-parented out of tables |
| 4 | [ ] Scripts are modules/defer; no document.write |
| 5 | [ ] Untrusted HTML sanitized with allowlist |
| 6 | [ ] Prefer textContent for plain text |
| 7 | [ ] Validate key templates in CI |
| 8 | [ ] Know when to open DevTools and diff trees |
When the parser encounters <svg> or <math>, it enters foreign content rules. Attribute casing and namespaces differ from HTML. Integration points allow certain HTML elements inside SVG (like foreignObject) to break back into HTML parsing.
| 1 | <div> |
| 2 | <svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true"> |
| 3 | <circle cx="20" cy="20" r="16" fill="#00FF41" /> |
| 4 | </svg> |
| 5 | <math> |
| 6 | <mi>x</mi><mo>=</mo><mn>2</mn> |
| 7 | </math> |
| 8 | </div> |
| 9 | |
| 10 | <svg> |
| 11 | <foreignObject width="100" height="40"> |
| 12 | <div xmlns="http://www.w3.org/1999/xhtml">HTML inside SVG</div> |
| 13 | </foreignObject> |
| 14 | </svg> |
note
The <template> element parses its contents into a separate document fragment associated with the template, not as live children of the template element in the usual sense. Scripts inside templates do not run until the content is cloned into a live document.
| 1 | const tpl = document.querySelector('#row'); |
| 2 | console.log(tpl.children.length); // 0 |
| 3 | console.log(tpl.content.childNodes.length); // > 0 |
| 4 | document.querySelector('tbody').append(tpl.content.cloneNode(true)); |
Misnested formatting elements (<b><i></b></i>) trigger the adoption agency algorithm — a recovery strategy that reshuffles formatting elements so the DOM remains usable. You should still fix the source; do not depend on the recovered shape for CSS or JS selectors.
| 1 | <!-- Misnested --> |
| 2 | <p><b>Bold <i>bold-italic</b> still?</i></p> |
| 3 | |
| 4 | <!-- Inspect the DOM — it will not match the source pairing exactly --> |
warning
Loading a page parses a full document stream with DOCTYPE and html/head/body creation. Setting innerHTML uses fragment parsing with a context element — for example, parsing inside a tr allows td tokens that would be illegal at the document root.
| 1 | const tr = document.createElement('tr'); |
| 2 | tr.innerHTML = '<td>A</td><td>B</td>'; |
| 3 | // Works because context is tr |
| 4 | |
| 5 | const div = document.createElement('div'); |
| 6 | div.innerHTML = '<td>A</td>'; |
| 7 | // td may be ignored or repaired — not a reliable cell |
pro tip
When you file a bug or write a regression test, name the concept. Reviewers understand “foster parenting” faster than “div jumped out of table.” Use this map:
| Observed bug | Spec concept | Fix direction |
|---|---|---|
| Block breaks out of paragraph | Implied end tags | Do not wrap blocks in p |
| Node appears before table | Foster parenting | Use valid table children |
| Formatting tags reshuffled | Adoption agency | Normalize editor HTML |
| Head tags end up in body | Insertion mode switch | Fix early body content |
| SVG attributes lowercased oddly | Foreign content | Adjust SVG authoring |
info
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.