1How Browsers Parse HTML into the DOM
▶
Every time you navigate to a webpage, your browser performs one of its most fundamental tasks: converting raw HTML text into a living, interactive structure that JavaScript can manipulate and the rendering engine can paint onto the screen. This structure is called the Document Object Model, or DOM. Understanding how that conversion happens — step by step — is essential for writing JavaScript that works reliably, debugging puzzling errors where scripts cannot find elements, and optimizing pages so they load as fast as possible. The journey from a stream of raw characters to a fully interactive page involves several distinct phases: receiving the HTML, tokenizing it into meaningful pieces, constructing a tree of nodes, and carefully managing the execution of scripts encountered along the way.
It is worth appreciating that the DOM is not the HTML source file itself. The HTML file is just text. The DOM is the browser's in-memory, object-oriented representation of that text — a tree of nodes, each with properties and methods, that JavaScript can read and modify at runtime. Changes you make via JavaScript update the DOM but do not alter the original HTML file on the server.
What Happens When a Browser Receives HTML
When you type a URL and press Enter, the browser sends an HTTP request to a server. The server responds with bytes. Those bytes are decoded into characters using the encoding specified in the HTTP header or in a <meta charset> tag — most commonly UTF-8. What the browser then has is a long stream of characters, starting with something like <!DOCTYPE html><html><head> and ending with </html>.
The browser does not wait for the entire document to arrive before it starts working. Modern browsers use an incremental, streaming parser. As bytes arrive over the network, the parser processes them from top to bottom, character by character. This means that elements near the top of the document — the <head>, early stylesheets, and the opening lines of the <body> — begin to be processed before the server has even finished sending the rest of the file.
Each recognized tag, attribute, and piece of text content is translated into a corresponding DOM object — a JavaScript-accessible node. For example, the characters <p class="intro">Hello</p> become an Element node of type HTMLParagraphElement, with a className property set to "intro", containing a child Text node whose data is "Hello". The final result of parsing is a tree of these objects — the DOM tree — rooted at the document object, which the browser uses both to render the page visually and to expose to JavaScript for programmatic interaction.
Tokenization: Breaking HTML into Meaningful Pieces
Before the browser can build nodes, it must break the character stream into discrete, meaningful units called tokens. Tokenization is the first stage of parsing. A token is the smallest chunk of HTML that carries complete, self-contained meaning to the parser. The main types of tokens are:
- DOCTYPE token — for example,
<!DOCTYPE html>, which signals the document type and affects quirks-mode behavior. - Start tag token — for example,
<div class="box">. This token carries the tag name and any associated attributes as name-value pairs. - End tag token — for example,
</div>. - Self-closing tag token — for example,
<img src="photo.jpg" alt="A photo">, which has no matching end tag. - Character token — a run of text content between tags.
- Comment token — for example,
<!-- This is a comment -->. - End-of-file token — signaling that the character stream has ended.
The tokenizer operates as a state machine. It begins in a "data" state, consuming characters one at a time. When it encounters a < character, it transitions to a "tag open" state. A / after < indicates an end tag. Attribute names and values are captured as the tokenizer moves through further states. This approach is defined precisely in the HTML5 parsing specification published by the WHATWG, which means every conforming browser tokenizes the same way — producing consistent results even for malformed markup.
That last point deserves emphasis. The HTML5 specification does not merely describe valid HTML; it also specifies exactly how to handle invalid HTML. If you write <p>Text</div>, the spec says what a parser must do — in this case, the mismatched end tag is handled by an error-recovery algorithm. This is why browsers do not display blank pages when they encounter mistakes; instead, they apply well-defined rules to produce a sensible DOM even from broken source code. The downside is that the DOM you get may not be what you intended, so valid HTML remains important.
Building the DOM Tree from Tokens
As tokens are produced, the tree construction stage processes them in sequence to build the DOM tree. The key tool in this stage is the stack of open elements, sometimes called the open element stack or the element stack. Think of it as a list tracking which elements are currently "open" — started but not yet closed.
The rules work as follows:
- When a start tag token is encountered, the parser creates a new element node for it, appends it as a child of the element currently at the top of the stack, and then pushes the new element onto the stack. It becomes the new "current parent" for anything that follows.
- When a character token is encountered, a text node is created and appended as a child of whatever element is currently at the top of the stack.
- When an end tag token is encountered, the parser pops the matching element off the stack. The element that was beneath it becomes the new current parent.
- Some elements are void elements — they cannot have children and have no end tag:
<img>,<br>,<input>,<hr>, and a few others. Their start tag token causes the parser to create the element node without pushing it onto the stack.
Consider a simple example:
<ul>
<li>First</li>
<li>Second</li>
</ul>
The parser processes this as follows:
| Token encountered | Action taken | Stack after action |
|---|---|---|
<ul> start tag |
Create ul element, append to body, push onto stack |
html → body → ul |
<li> start tag |
Create li element, append to ul, push onto stack |
html → body → ul → li |
| Character: "First" | Create text node "First", append to li |
html → body → ul → li |
</li> end tag |
Pop li off the stack |
html → body → ul |
<li> start tag |
Create new li element, append to ul, push onto stack |
html → body → ul → li |
| Character: "Second" | Create text node "Second", append to new li |
html → body → ul → li |
</li> end tag |
Pop li off the stack |
html → body → ul |
</ul> end tag |
Pop ul off the stack |
html → body |
The finished tree mirrors the nesting structure of the original markup. The deeper an element is nested in the HTML, the deeper it sits in the DOM tree. This direct correspondence is why understanding HTML nesting is so useful when working with the DOM in JavaScript — methods like querySelector, closest, and children all operate on this tree structure.
How Parser-Blocking Scripts Affect Parsing
The HTML parser processes the document from top to bottom, but it must pause — block — whenever it encounters a <script> element that does not carry a defer or async attribute. This behavior exists because a script might call document.write(), which can inject new HTML directly into the stream being parsed, potentially changing what comes next. To keep that possible, the browser stops parsing, waits for the script file to download (if it is an external file), then executes the script, and only then resumes parsing the rest of the document.
Consider this structure:
<!DOCTYPE html>
<html>
<head>
<script src="heavy-library.js"></script>
</head>
<body>
<h1>Welcome</h1>
<p>Page content here.</p>
</body>
</html>
In this scenario, the parser reaches the <script> tag in the <head>, stops, downloads heavy-library.js (which might take several hundred milliseconds on a slow connection), executes it, and only then continues to parse the <body>. The user sees a blank or incomplete page for the entire duration of that download and execution. The <h1> and <p> elements do not even exist in the DOM yet.
A classic solution is to move scripts to just before the closing </body> tag:
<!DOCTYPE html>
<html>
<head></head>
<body>
<h1>Welcome</h1>
<p>Page content here.</p>
<script src="heavy-library.js"></script>
</body>
</html>
Now the parser builds and (after stylesheets load) renders the <h1> and <p> elements before it encounters the script. The blocking still occurs, but by then the user has already seen the page content. This technique was the industry standard for many years before the defer and async attributes became widely supported.
There is one additional subtlety: browsers use a secondary process called the preload scanner (or speculative parser). Even while the main parser is blocked by a script, the preload scanner looks ahead in the raw HTML bytes and dispatches network requests for other resources it spots — other scripts, stylesheets, images. This reduces the cascading delay that would otherwise occur. However, the preload scanner cannot build DOM nodes; that work remains blocked until the current script finishes executing.
The defer and async Attributes
HTML5 introduced two attributes on the <script> element that give developers fine-grained control over the download and execution timing of external scripts without blocking the parser.
The async attribute tells the browser: "Download this script in parallel with the rest of the parsing, and execute it as soon as it has finished downloading — even if the HTML is not fully parsed yet." Consider:
<script async src="analytics.js"></script>
async is best suited for scripts that are completely independent — they do not rely on other scripts, and nothing else relies on them. Web analytics trackers are a perfect example: they do not need the DOM to be fully built, and no other script depends on them. The critical caveat is that async scripts do not execute in order. If you have two async scripts, whichever finishes downloading first will execute first, regardless of their order in the HTML.
The defer attribute tells the browser: "Download this script in parallel, but do not execute it until the HTML has been fully parsed." Consider:
<script defer src="main-app.js"></script>
defer is ideal for scripts that need to interact with DOM elements — since execution is deferred until parsing completes, those elements are guaranteed to exist. Crucially, deferred scripts execute in the order they appear in the HTML, which makes it safe to split application code across multiple deferred files while preserving dependency order.
The behavioral differences are summarized below:
| Attribute | Download timing | Execution timing | Execution order | Blocks parsing? |
|---|---|---|---|---|
| Neither (default) | Immediately, blocks parsing during download | Immediately after download | In order | Yes |
async |
In parallel with parsing | As soon as downloaded, may interrupt parsing | Not guaranteed | Only during execution |
defer |
In parallel with parsing | After HTML is fully parsed | In order | No |
Both async and defer apply only to external scripts (those with a src attribute). Inline scripts — where the JavaScript is written directly between <script> tags — always execute immediately and block the parser; those attributes have no effect on them.
The DOMContentLoaded Event and Parse Completion
Once the HTML parser has processed every token in the document and the DOM tree is fully constructed, the browser fires a special event on the document object: DOMContentLoaded. This event is your reliable signal that every element in the HTML now exists as a node in the DOM — you can safely query, traverse, and modify the tree.
document.addEventListener('DOMContentLoaded', function() {
// Safe to query the DOM here
const heading = document.querySelector('h1');
heading.textContent = 'Page is ready!';
});
If you attempt to query an element before DOMContentLoaded fires — for instance, in a parser-blocking script in the <head> that tries to select a <body> element — the query returns null because the parser has not yet reached and created that element. This is one of the most common sources of JavaScript errors for beginners, and understanding the parsing sequence explains exactly why it happens.
Deferred scripts are closely tied to this event. The specification guarantees that all deferred scripts execute just before DOMContentLoaded fires. This means if you add a defer attribute to your scripts, you do not even need a DOMContentLoaded listener — by the time your deferred script runs, the DOM is already complete.
It is important to distinguish DOMContentLoaded from the load event, which fires on the window object:
window.addEventListener('load', function() {
// Fires after ALL resources have loaded:
// images, stylesheets, fonts, iframes, etc.
});
The load event fires much later than DOMContentLoaded — potentially seconds later on image-heavy pages — because it waits for every dependent resource to finish loading. Use DOMContentLoaded for DOM manipulation; use load only when you genuinely need all resources (for example, to read the dimensions of loaded images).
Parsing Order and Its Impact on Page Rendering
Because the parser processes HTML from top to bottom, order matters enormously for both rendering performance and JavaScript correctness.
Content appearing earlier in the document is parsed and available for rendering sooner. This is why the visible, above-the-fold content of a page should come as early in the <body> as possible, and why large scripts or heavy resource references should come later. Moving a 200 KB blocking script from the <head> to just before </body> can shave hundreds of milliseconds off the time until a user sees actual content.
Stylesheets add a nuance. A <link rel="stylesheet"> in the <head> does not block the parser — the parser keeps building the DOM while the CSS file downloads. However, stylesheets do block rendering. The browser will not paint any pixels until both the DOM and the CSSOM (CSS Object Model) are ready and combined into the render tree. This is intentional: painting without CSS would briefly show unstyled content (a "flash of unstyled content," or FOUC), which is a poor user experience. The practical consequence is that placing critical stylesheets in the <head> is correct — you want CSS to load as early as possible so it does not delay the first paint any more than necessary.
Scripts placed in the <head> without defer or async block both parsing and rendering because the browser cannot safely continue building the DOM (in case the script uses document.write) and cannot render anything while the parser is blocked. Furthermore, if a stylesheet precedes a script in the <head>, the browser may delay script execution until the stylesheet has loaded, because the script might query computed styles — another interaction between the CSS and JavaScript pipelines that can compound delays.
A practical, modern strategy combining these insights looks like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="styles.css"> <!-- CSS in head, blocks render but not parse -->
<script defer src="app.js"></script> <!-- defer: downloads in parallel, runs after parse -->
</head>
<body>
<h1>Visible content first</h1>
<p>The parser reaches this immediately after the head.</p>
</body>
</html>
In this pattern, the stylesheet downloads (blocking the first paint but not parsing), the script downloads in parallel without blocking the parser, the body content is parsed and made available for rendering as soon as the CSS is ready, and the script executes only after the full DOM is built — exactly when it is safe to manipulate elements.
Awareness of the parsing pipeline underpins two of the most important skill areas in front-end development. Debugging: when JavaScript throws "Cannot read properties of null" on a DOM query, the root cause is almost always that the script ran before the parser had created the targeted element. Performance optimization: the metrics that define a fast page — First Contentful Paint, Largest Contentful Paint, Time to Interactive — are all directly influenced by when the parser can build and render each part of the document. Building a clear mental model of how browsers parse HTML is not an academic exercise; it is one of the most practically useful things a web developer can understand.