HTML parsing is the process of reading HTML text and turning it into a tree of objects, the Document Object Model (DOM), that a browser or a program can work with. A browser parses every page it loads before it can style, lay out or run scripts on it. Your own code parses HTML whenever it scrapes a page, sanitises user input or extracts data from a document.
This guide explains how the HTML parser in a browser works (tokenization and tree construction), why it never rejects broken HTML, and how parsers in JavaScript, Python and Node.js compare. I fed the same five pieces of malformed HTML to a real browser and to six popular parsers, and they did not all agree. The results are below, along with timings on a real 300 KB page.
What Does HTML Parsing Mean?
“Parsing” means analysing text according to a grammar and building a structured representation from it. For HTML, the input is a stream of characters such as <p class="intro">Hello <b>world</b></p>, and the output is a tree: a p element node with a class attribute, containing a text node “Hello ” and a b element, which contains the text node “world”.
That tree is what JavaScript sees through document.querySelector, what CSS selectors match against, and what a scraper walks to find data. The structure of the tree, its node types and how to traverse it are covered in the DOM tree structure explained.
Unlike XML, HTML has no “invalid document” outcome. The WHATWG HTML standard defines exactly what a parser must do for every input, including unclosed tags, misnested tags and stray text. That is why every modern browser builds the same DOM from the same broken markup.
How a Browser Parses HTML
The HTML parser is one stage of the browser’s rendering pipeline (see how the rendering engine works for the stages that come after it). Inside the parser, the standard defines four steps:
- Decoding bytes into characters. The network delivers bytes. The parser picks a character encoding from the byte order mark, the HTTP
Content-Typeheader or a<meta charset>tag in the first 1,024 bytes, and decodes the bytes into characters. This is why the charset declaration belongs at the very top of<head>. - Pre-processing. Line endings are normalised and certain invalid characters are reported as parse errors.
- Tokenization. A state machine reads characters one at a time and emits tokens: DOCTYPE, start tag (with its attributes), end tag, comment, character and end-of-file. Its state changes with what it has just read (data state, tag open state, attribute name state and so on).
- Tree construction. Each token goes to the tree builder, which creates nodes and inserts them into the DOM. It keeps a stack of open elements to know where the next node goes, and an insertion mode (“in head”, “in body”, “in table”…) that decides how each token is handled.
The two steps feed each other: the tree builder can switch the tokenizer’s state. For example, after a <script> or <textarea> start tag, the contents are read as raw text instead of markup. That is why the characters <b> inside a script aren’t treated as a tag.
Tokenization in practice
Python’s standard library exposes a tokenizer-style parser that reports tokens as events, without building a tree. Feeding it a short fragment shows the token stream:
from html.parser import HTMLParser
class Show(HTMLParser):
def handle_starttag(self, tag, attrs): print('start', tag, attrs)
def handle_endtag(self, tag): print('end ', tag)
def handle_data(self, data): print('data ', repr(data))
Show().feed('<p class="intro">Hello <b>world</b></p>')start p [('class', 'intro')]
data 'Hello '
start b []
data 'world'
end b
end pA browser’s tree builder consumes the same kind of stream. For well-formed input the result is obvious; the interesting part is what happens when the input is broken.
Tree construction and error recovery
The tree builder never gives up. When it meets markup that doesn’t fit, it applies a rule from the standard. The main ones:
- Implied tags. A missing
<html>,<head>or<body>is created for you. Parsing<title>T</title><p>Hiin Chromium gave<html><head><title>T</title></head><body><p>Hi</p></body></html>. - Auto-closing. Some elements close automatically: a new
<p>or a block element such as<div>closes an open paragraph, and a new<li>closes the previous one. - The adoption agency algorithm. Misnested formatting tags such as
<b><i>…</b>…</i>are repaired by closing and reopening elements so that the styling the author probably intended survives. - Foster parenting. Content that isn’t allowed inside a table, such as a
<div>between rows, is moved out and inserted just before the table.
Same Broken HTML, Different Parsers
To see how much these rules matter, I parsed five malformed snippets with Chromium 156’s DOMParser (in headless Brave) and with six parsers: parse5 8.1, cheerio 1.2 and node-html-parser 9.0.6 in Node.js, and BeautifulSoup 4.15 with Python’s html.parser, lxml 6.1 and html5lib 1.1. Each cell shows the serialised body.
Unclosed paragraphs: <p>One<p>Two
| Parser | Result | |
|---|---|---|
| Chromium (DOMParser) | <p>One</p><p>Two</p> | Reference |
| parse5 / cheerio | <p>One</p><p>Two</p> | Same as the browser |
| html5lib | <p>One</p><p>Two</p> | Same as the browser |
| node-html-parser | <p>One</p><p>Two</p> | Same as the browser |
| lxml | <p>One</p><p>Two</p> | Same as the browser |
| Python html.parser | <p>One<p>Two</p></p> | Different |
Misnested tags: <b><i>bold italic</b> plain?</i>
| Parser | Result | |
|---|---|---|
| Chromium (DOMParser) | <b><i>bold italic</i></b><i> plain?</i> | Reference |
| parse5 / cheerio | <b><i>bold italic</i></b><i> plain?</i> | Same as the browser |
| html5lib | <b><i>bold italic</i></b><i> plain?</i> | Same as the browser |
| node-html-parser | <b><i>bold italic plain?</i></b> | Different |
| lxml | <b><i>bold italic</i></b> plain? | Different |
| Python html.parser | <b><i>bold italic</i></b> plain? | Different |
div inside a table: <table><tr><td>cell</td></tr><div>stray</div></table>
| Parser | Result | |
|---|---|---|
| Chromium (DOMParser) | <div>stray</div><table><tbody><tr><td>cell</td></tr></tbody></table> | Reference |
| parse5 / cheerio | <div>stray</div><table><tbody><tr><td>cell</td></tr></tbody></table> | Same as the browser |
| html5lib | <div>stray</div><table><tbody><tr><td>cell</td></tr></tbody></table> | Same as the browser |
| node-html-parser | <table><tr><td>cell</td></tr><div>stray</div></table> | Different |
| lxml | <table><tr><td>cell</td></tr><div>stray</div></table> | Different |
| Python html.parser | <table><tr><td>cell</td></tr><div>stray</div></table> | Different |
div inside a p: <p>Para <div>block</div> after</p>
| Parser | Result | |
|---|---|---|
| Chromium (DOMParser) | <p>Para </p><div>block</div> after<p></p> | Reference |
| parse5 / cheerio | <p>Para </p><div>block</div> after<p></p> | Same as the browser |
| html5lib | <p>Para </p><div>block</div> after<p></p> | Same as the browser |
| node-html-parser | <p>Para </p><div>block</div> after | Different |
| lxml | <p>Para </p><div>block</div> after | Different |
| Python html.parser | <p>Para <div>block</div> after</p> | Different |
Unclosed list items: <ul><li>One<li>Two</ul>
| Parser | Result | |
|---|---|---|
| Chromium (DOMParser) | <ul><li>One</li><li>Two</li></ul> | Reference |
| parse5 / cheerio | <ul><li>One</li><li>Two</li></ul> | Same as the browser |
| html5lib | <ul><li>One</li><li>Two</li></ul> | Same as the browser |
| node-html-parser | <ul><li>One</li><li>Two</li></ul> | Same as the browser |
| lxml | <ul><li>One</li><li>Two</li></ul> | Same as the browser |
| Python html.parser | <ul><li>One<li>Two</li></li></ul> | Different |
What the results show:
- parse5, cheerio and html5lib matched Chromium in all five cases. They implement the WHATWG algorithm, so they give you the same DOM a browser does. Cheerio uses parse5 for HTML documents by default.
- node-html-parser handled simple auto-closing (paragraphs, list items) but not the adoption agency or foster-parenting rules: it kept the
divinside the table and swallowed ” plain?” into the bold element. - lxml (built on libxml2) fixed paragraphs and list items but kept the stray
divinside the table and dropped the trailing italic. - Python’s html.parser did no HTML-specific repair at all: it nested the second paragraph inside the first and the second list item inside the first, which no browser would do.
For clean, valid HTML, all of them produce the same tree. The differences matter when you scrape real-world pages, which are often broken, and when you sanitise HTML for security: if your sanitiser parses markup differently from the browser that later renders it, an attacker can hide content in the gap. Sanitise with a spec-compliant parser, or with the browser itself.
How Fast Are HTML Parsers?
I timed each parser on the same real page, a 304 KB article from this site with about 1,100 elements, taking the median of 10-12 runs on an Apple M2. The Python numbers include building BeautifulSoup’s tree.
| Parser | Median time | Spec-compliant tree? |
|---|---|---|
| node-html-parser (Node.js) | 1.9 ms | No |
| parse5 (Node.js) | 7.9 ms | Yes |
| BeautifulSoup + lxml (Python) | 10.2 ms | No |
| cheerio (Node.js, parse5 inside) | 11.0 ms | Yes |
| BeautifulSoup + html.parser (Python) | 13.6 ms | No |
| BeautifulSoup + html5lib (Python) | 47.6 ms | Yes |
The trade-off is clear: the fastest parsers skip the error-recovery rules. For one page, every option is fast enough. For millions of pages, node-html-parser or lxml can save real time, as long as your target pages are reasonably well formed.
How to Parse HTML in Code
In the browser: DOMParser
const html = '<ul><li>One<li>Two</ul>';
const doc = new DOMParser().parseFromString(html, 'text/html');
console.log([...doc.querySelectorAll('li')].map(li => li.textContent)); // ['One', 'Two']DOMParser builds a separate, inert document: scripts in it don’t run and images don’t load, so it is a safe way to inspect markup. Don’t insert untrusted HTML into the live page with innerHTML; use a sanitiser such as DOMPurify first.
In Node.js: parse5 or cheerio
import * as cheerio from 'cheerio';
const $ = cheerio.load('<ul><li>One<li>Two</ul>');
console.log($('li').map((i, el) => $(el).text()).get()); // [ 'One', 'Two' ]Use cheerio when you want a jQuery-style API for scraping, parse5 when you need the raw spec-compliant tree (jsdom uses it), and node-html-parser when speed matters more than browser-identical results.
In Python: BeautifulSoup
from bs4 import BeautifulSoup
soup = BeautifulSoup('<ul><li>One<li>Two</ul>', 'html5lib') # or 'lxml', 'html.parser'
print([li.get_text() for li in soup.find_all('li')]) # ['One', 'Two']BeautifulSoup is a wrapper; the second argument picks the actual parser. Choose html5lib for browser-identical results, lxml for speed, and html.parser when you can’t install anything. With html.parser, this same snippet printed ['OneTwo', 'Two']: the second item ends up nested inside the first, as the comparison above showed, so the first item’s text includes both. For pages that need JavaScript to render, parse the HTML after a real browser has run it; see web scraping with Selenium and Python. For PHP, see parsing HTML with DOMDocument.
Why you shouldn’t parse HTML with regular expressions
HTML is nested, attributes can appear in any order and quoting style, and the error-recovery rules above change the structure. A regex that works on today’s page breaks on tomorrow’s. Use a parser and query the tree with CSS selectors or XPath; it is shorter and more reliable.
How HTML Parsing Affects Page Speed
- Classic scripts block the parser. When the parser reaches a
<script>withoutasyncordefer, it stops, downloads and runs the script, then continues, because the script might calldocument.writeand change the markup still to be parsed. - defer and async remove the block.
deferdownloads in parallel and runs the script after parsing finishes, in order;asyncruns it as soon as it arrives. Module scripts (type="module") are deferred by default. - The preload scanner helps. While the parser is blocked, browsers scan ahead in the raw HTML for images, stylesheets and scripts and start downloading them early. Resources injected later by JavaScript can’t benefit from it.
- Big documents take longer. More markup means more tokens and nodes; see how to reduce DOM size.
CSS goes through a separate parser of its own, which builds the CSSOM; see CSS parsing explained.
Conclusion
HTML parsing turns text into the DOM in two main steps: a tokenizer that emits tags, text and comments, and a tree builder that places them using a stack of open elements and the standard’s error-recovery rules. Because those rules are fully specified, all browsers build the same tree from broken markup, and so do spec-compliant libraries such as parse5, cheerio and html5lib. Faster parsers like node-html-parser and lxml skip some of the rules, and Python’s html.parser skips almost all of them, which is fine for clean HTML but risky for scraping messy pages or sanitising untrusted input.
FAQs
What is HTML parsing in simple terms?
It is reading HTML text and turning it into a tree of objects (the DOM) that a browser or program can work with. The parser splits the text into tokens such as tags and text, then builds the tree from them.
What is an HTML parser?
An HTML parser is the software that does this conversion. Browsers have one built in; libraries such as parse5, cheerio, BeautifulSoup, lxml and html5lib let your own code parse HTML.
What data structure is generated from parsing an HTML document?
A tree: the Document Object Model (DOM). Each element, text run and comment becomes a node, with parent-child relationships that mirror the nesting of the HTML.
Does the browser stop on invalid HTML?
No. The HTML standard defines how to handle every error, so the parser repairs the markup: it adds missing html, head and body elements, closes unclosed paragraphs and list items, and moves misplaced content, and all browsers do it the same way.
Which Python HTML parser should I use?
Use BeautifulSoup with html5lib if you need the same tree as a browser, with lxml if you need speed, and with the built-in html.parser only for clean HTML or when you cannot install packages.
Can I parse HTML with regex?
Only for tiny, fixed snippets you control. Real HTML is nested and often malformed, so use a parser and select elements with CSS selectors or XPath instead.








