How the DOM Tree Works in JavaScript
Every web page you see in the browser starts as plain HTML text. But JavaScript does not interact with that raw text. Instead, the browser parses the HTML and transforms it into a structured, tree-shaped representation called the DOM tree (Document Object Model tree). Every tag, every piece of text, and even every comment becomes a node in this tree.
Understanding the DOM tree is essential for manipulating web pages with JavaScript. If you know how the browser builds the tree and what types of nodes exist, you will be able to traverse, search, and modify any part of a page with confidence.
This guide walks you through how HTML becomes a DOM tree, what node types exist, how whitespace creates unexpected nodes, how the browser silently fixes broken HTML, and how to inspect it all in DevTools.
HTML Document to DOM Tree
When the browser receives an HTML document, it does not display the raw text. It goes through a process called parsing, which reads the HTML character by character and builds a tree of objects in memory. This tree is the DOM.
Consider this HTML document:
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
</head>
<body>
<h1>Hello!</h1>
<p>Welcome to my page.</p>
</body>
</html>
The browser transforms this into a tree structure where every element becomes a node, and nesting in the HTML becomes a parent-child relationship in the tree:
document
│
└── DOCTYPE: html
│
└── HTML
├── HEAD
│ ├── #text: "\n "
│ ├── TITLE
│ │ └── #text: "My Page"
│ └── #text: "\n "
│
└── BODY
├── #text: "\n "
├── H1
│ └── #text: "Hello!"
├── #text: "\n "
├── P
│ └── #text: "Welcome to my page."
└── #text: "\n "
Notice a few things immediately:
- Tags become element nodes (HTML, HEAD, BODY, H1, P, TITLE).
- Text content inside tags becomes text nodes (
#text). - Even the whitespace (newlines and spaces used for indentation) becomes text nodes.
- The entire tree starts from a root
documentnode.
Every single piece of the HTML source is represented somewhere in this tree. Nothing is discarded. This is a fundamental principle of the DOM: everything in the HTML becomes a node.
Visualizing Parent-Child Relationships
The nesting of HTML tags directly determines the parent-child relationships in the DOM tree:
<body>
<div>
<p>Some <strong>bold</strong> text</p>
</div>
</body>
This produces:
BODY
└── DIV
└── P
├── #text: "Some "
├── STRONG
│ └── #text: "bold"
└── #text: " text"
Here, BODY is the parent of DIV. DIV is the parent of P. The P element has three children: a text node, the STRONG element, and another text node. The STRONG element itself has one child: the text node "bold".
This tree structure is what JavaScript navigates when you use methods like querySelector, parentNode, childNodes, and nextSibling.
Node Types
Not everything in the DOM tree is the same kind of node. The DOM specification defines 12 node types, but in practice you will encounter four main types when working with HTML documents.
Element Nodes
Element nodes represent HTML tags. Every <div>, <p>, <h1>, <span>, <img>, and any other tag in your HTML becomes an element node in the DOM.
<div id="container">
<p class="intro">Hello</p>
</div>
Here, both <div> and <p> are element nodes. Element nodes can have attributes (like id and class), can contain other nodes (children), and are the nodes you interact with most often in JavaScript.
let div = document.querySelector("#container");
console.log(div.nodeType); // 1 (Node.ELEMENT_NODE)
console.log(div.nodeName); // "DIV"
Text Nodes
Text nodes contain the actual text content inside elements. They cannot have children (they are always leaf nodes in the tree).
<p>Hello, World!</p>
The <p> element node has one child: a text node with the content "Hello, World!".
let p = document.querySelector("p");
let textNode = p.firstChild;
console.log(textNode.nodeType); // 3 (Node.TEXT_NODE)
console.log(textNode.nodeName); // "#text"
console.log(textNode.data); // "Hello, World!"
Comment Nodes
HTML comments also become nodes in the DOM tree. This might seem surprising, but the DOM rule is clear: everything in the HTML is part of the tree.
<!-- This is a comment -->
<p>Hello</p>
The DOM tree includes both the comment and the paragraph:
BODY
├── #comment: " This is a comment "
├── #text: "\n"
└── P
└── #text: "Hello"
let body = document.body;
let commentNode = body.childNodes[0]; // The comment node (may vary with whitespace)
// Find the comment by iterating
for (let node of body.childNodes) {
if (node.nodeType === 8) { // Node.COMMENT_NODE
console.log("Found comment:", node.data); // " This is a comment "
}
}
You might wonder why comments are in the DOM at all. The principle is simple: the DOM must represent the complete HTML document. Comments do not affect the visual output, but they are part of the source, so they become nodes. JavaScript can read, modify, or remove them just like any other node.
The Document Node
At the very top of the tree sits the document node. It is the root of the entire DOM tree and the entry point for all DOM operations.
console.log(document.nodeType); // 9 (Node.DOCUMENT_NODE)
console.log(document.nodeName); // "#document"
The document node is not an HTML element. It is a special node that represents the entire document. Its children include the <!DOCTYPE> declaration and the <html> element.
Node Type Reference Table
| Node Type | nodeType Value | Constant | nodeName Example | Description |
|---|---|---|---|---|
| Element | 1 | Node.ELEMENT_NODE | "DIV", "P" | HTML tags |
| Text | 3 | Node.TEXT_NODE | "#text" | Text content (including whitespace) |
| Comment | 8 | Node.COMMENT_NODE | "#comment" | HTML comments <!-- --> |
| Document | 9 | Node.DOCUMENT_NODE | "#document" | The root document |
| DocumentType | 10 | Node.DOCUMENT_TYPE_NODE | "html" | The <!DOCTYPE> |
| DocumentFragment | 11 | Node.DOCUMENT_FRAGMENT_NODE | "#document-fragment" | A lightweight container |
You can always check a node's type using the nodeType property:
function describeNode(node) {
switch (node.nodeType) {
case Node.ELEMENT_NODE:
console.log(`Element: <${node.tagName.toLowerCase()}>`);
break;
case Node.TEXT_NODE:
console.log(`Text: "${node.data.trim() || "(whitespace)"}"`);
break;
case Node.COMMENT_NODE:
console.log(`Comment: "${node.data}"`);
break;
case Node.DOCUMENT_NODE:
console.log("Document node");
break;
}
}
// Test it
describeNode(document.body); // Element: <body>
describeNode(document.body.firstChild); // Text: "(whitespace)" (likely a newline)
Every Tag Is a Node, Every Text Is a Node
This principle is worth emphasizing because it catches many developers off guard. Let's look at a more complex example:
<ul>
<li>First <em>item</em></li>
<li>Second item</li>
</ul>
The DOM tree for this markup is:
UL
├── #text: "\n "
├── LI
│ ├── #text: "First "
│ └── EM
│ └── #text: "item"
├── #text: "\n "
├── LI
│ └── #text: "Second item"
└── #text: "\n"
Count the children of <ul>: it has 5 child nodes, not 2. The two <li> elements are there, but so are three text nodes containing whitespace (the newlines and spaces between the tags).
This is exactly why childNodes.length often returns a number that seems too high:
let ul = document.querySelector("ul");
console.log(ul.childNodes.length); // 5 (not 2!)
// If you only want element children:
console.log(ul.children.length); // 2 (only the <li> elements)
The first <li> itself has 2 child nodes: the text node "First " and the <em> element. The <em> element has one child: the text node "item".
let firstLi = ul.querySelector("li");
console.log(firstLi.childNodes.length); // 2
console.log(firstLi.childNodes[0]); // #text "First "
console.log(firstLi.childNodes[1]); // <em>item</em>
Whitespace Text Nodes
Whitespace text nodes are one of the most common sources of confusion when working with the DOM. Every newline, space, or tab between tags in your HTML source becomes a text node.
Where Whitespace Nodes Appear
<body>
<p>Hello</p>
<p>World</p>
</body>
A developer might expect <body> to have two children: the two <p> elements. But the actual DOM tree is:
BODY
├── #text: "\n " ← whitespace before first <p>
├── P
│ └── #text: "Hello"
├── #text: "\n " ← whitespace between the two <p> elements
├── P
│ └── #text: "World"
└── #text: "\n" ← whitespace after last <p>
let body = document.body;
console.log(body.childNodes.length); // 5
// The first child is NOT <p>. It's a whitespace text node.
console.log(body.firstChild.nodeType); // 3 (TEXT_NODE)
console.log(body.firstChild.data); // "\n "
// The first ELEMENT child is <p>
console.log(body.firstElementChild.tagName); // "P"
Why This Matters
Whitespace nodes can break your code if you assume children are all elements:
// WRONG: assumes firstChild is an element
let body = document.body;
body.firstChild.style.color = "red";
// TypeError: Cannot set properties of undefined
// because firstChild is a text node, not an element!
// CORRECT: use firstElementChild to skip text nodes
let body = document.body;
body.firstElementChild.style.color = "red"; // Works!
Element-Only Navigation Properties
To avoid whitespace text node issues, the DOM provides element-only navigation properties:
| All Nodes | Elements Only |
|---|---|
parentNode | parentElement |
childNodes | children |
firstChild | firstElementChild |
lastChild | lastElementChild |
nextSibling | nextElementSibling |
previousSibling | previousElementSibling |
let body = document.body;
// childNodes includes text nodes
for (let node of body.childNodes) {
console.log(node.nodeType, node.nodeName);
}
// 3 "#text"
// 1 "P"
// 3 "#text"
// 1 "P"
// 3 "#text"
// children includes only element nodes
for (let element of body.children) {
console.log(element.tagName);
}
// "P"
// "P"
In most cases, you should use children, firstElementChild, nextElementSibling, and other element-only properties. Use childNodes only when you specifically need to access text or comment nodes.
Two Notable Exceptions to Whitespace Nodes
There are two places where the browser does not create whitespace text nodes:
1. Before <head>: Whitespace before the <head> tag is ignored by the parser. The <html> element's first child is always the <head> element.
2. After </body>: Any content placed after the closing </body> tag is moved inside <body> by the browser. The parser does not create nodes outside <body> at the end of the document.
<!DOCTYPE html>
<html>
<head>
<title>Test</title>
</head>
<body>
<p>Content</p>
</body>
</html>
Despite the whitespace between <html> and <head>, the <html> element's first child is <head>, not a whitespace text node. The whitespace between </head> and <body>, however, does become a text node.
Auto-Correction by the Browser
Browsers are remarkably forgiving when parsing HTML. If your HTML is malformed, missing tags, or structured incorrectly, the browser will silently fix it when building the DOM tree. The DOM you end up with may look different from the HTML you wrote.
Missing <html>, <head>, and <body>
Even if you write the most minimal HTML, the browser will create a complete document structure:
Hello, World!
The browser builds this DOM tree:
#document
└── HTML
├── HEAD
└── BODY
└── #text: "Hello, World!"
The <html>, <head>, and <body> tags were all added automatically. The text was placed inside <body>.
// Even with minimal HTML, these always exist:
console.log(document.documentElement); // <html>
console.log(document.head); // <head>
console.log(document.body); // <body>
Unclosed Tags
The browser automatically closes tags that are left open:
<p>First paragraph
<p>Second paragraph
Becomes:
BODY
├── P
│ └── #text: "First paragraph\n"
└── P
└── #text: "Second paragraph\n"
Each <p> is automatically closed before the next one begins.
Tables Get Auto-Corrected
Tables have particularly strict rules. The browser ensures that <table> always contains <tbody>, even if you did not write one:
<table>
<tr>
<td>Cell 1</td>
<td>Cell 2</td>
</tr>
</table>
The DOM tree will be:
TABLE
└── TBODY ← added automatically by the browser
└── TR
├── TD
│ └── #text: "Cell 1"
└── TD
└── #text: "Cell 2"
let table = document.querySelector("table");
console.log(table.firstElementChild.tagName); // "TBODY" (not "TR"!)
This auto-correction is a very common source of bugs. If you write JavaScript that expects <tr> to be a direct child of <table>, it will fail because the browser inserted a <tbody> in between.
// WRONG: assumes <tr> is a direct child of <table>
let table = document.querySelector("table");
let firstRow = table.querySelector(":scope > tr"); // null!
// CORRECT: account for <tbody>
let firstRow2 = table.querySelector("tbody > tr"); // Works
// Or simply:
let firstRow3 = table.querySelector("tr"); // Also works (searches descendants)
Misplaced Content Gets Moved
If you place content in places where it is not allowed according to the HTML specification, the browser moves it:
<table>
<div>This div is not allowed here</div>
<tr><td>Cell</td></tr>
</table>
The browser moves the <div> outside the table (before it) and places the <tr> inside an auto-generated <tbody>:
DIV
└── #text: "This div is not allowed here"
TABLE
└── TBODY
└── TR
└── TD
└── #text: "Cell"
What Auto-Correction Means for You
The key takeaway is: the DOM does not always match your HTML source. The browser's parser applies a set of rules to produce a valid DOM tree. When debugging, always inspect the actual DOM (using DevTools) rather than assuming it matches your source HTML.
Viewing the DOM in DevTools
Every modern browser includes Developer Tools (DevTools) that let you inspect the live DOM tree. This is the single most important tool for understanding and debugging DOM-related code.
Opening DevTools
| Browser | Shortcut (Windows/Linux) | Shortcut (Mac) |
|---|---|---|
| Chrome | F12 or Ctrl + Shift + I | Cmd + Option + I |
| Firefox | F12 or Ctrl + Shift + I | Cmd + Option + I |
| Edge | F12 or Ctrl + Shift + I | Cmd + Option + I |
| Safari | Enable in Preferences first | Cmd + Option + I |
The Elements Panel
The Elements panel (called Inspector in Firefox) shows the DOM tree in a collapsible, editable view. This is the live DOM, not the original HTML source.
Key things you can do in the Elements panel:
Inspect an element: Right-click any element on the page and select "Inspect" to jump directly to it in the DOM tree.
Expand and collapse nodes: Click the arrows next to elements to see their children.
Edit the DOM live: Double-click a tag name, attribute, or text content to edit it directly. Changes are immediately reflected on the page.
Delete a node: Select a node and press the Delete key.
View element details: The right sidebar shows the element's styles, computed properties, event listeners, and more.
Inspecting Specific Nodes in the Console
DevTools provides a convenient way to interact with elements you have selected in the Elements panel:
// $0 refers to the currently selected element in the Elements panel
console.log($0);
console.log($0.tagName);
console.log($0.childNodes);
console.log($0.textContent);
// $1 is the previously selected element, $2 the one before that, etc.
You can also use selector shortcuts in the Console:
// Shortcut for document.querySelector
$("p.intro");
// Shortcut for document.querySelectorAll
$$("p");
// XPath query
$x("//p");
The $() and $$() shortcuts are DevTools features, not part of JavaScript. They work in the Console panel but will throw errors if used in your actual code (unless you are using jQuery, which defines its own $()).
Seeing Text Nodes and Whitespace
By default, the Elements panel in Chrome hides some whitespace text nodes to keep the display clean. To see all nodes including whitespace:
- Open DevTools and go to the Elements panel.
- Whitespace-only text nodes may be collapsed or hidden.
- To inspect them, use the Console panel:
// See ALL child nodes, including whitespace text nodes
let body = document.body;
for (let node of body.childNodes) {
console.log(
`Type: ${node.nodeType}, Name: ${node.nodeName}, ` +
`Value: ${JSON.stringify(node.nodeValue)}`
);
}
Output example:
Type: 3, Name: #text, Value: "\n "
Type: 1, Name: H1, Value: null
Type: 3, Name: #text, Value: "\n "
Type: 1, Name: P, Value: null
Type: 3, Name: #text, Value: "\n"
Viewing the DOM as JavaScript Objects
You can also inspect DOM nodes as regular JavaScript objects in the Console to see all their properties and methods:
// See the DOM element in its HTML representation
console.log(document.body);
// See the DOM element as a JavaScript object with all properties
console.dir(document.body);
console.log() shows the HTML representation (what you see in the Elements panel). console.dir() shows the JavaScript object representation with all properties like tagName, classList, style, childNodes, and hundreds of others.
Practical DevTools Exercise
Try this exercise to solidify your understanding. Open any web page, open DevTools, and run the following in the Console:
// Count all nodes in the entire document
function countNodes(node) {
let count = 1; // Count this node
for (let child of node.childNodes) {
count += countNodes(child);
}
return count;
}
console.log("Total nodes in document:", countNodes(document));
// Break down by type
function countByType(node, counts = {}) {
let typeName = node.constructor.name;
counts[typeName] = (counts[typeName] || 0) + 1;
for (let child of node.childNodes) {
countByType(child, counts);
}
return counts;
}
console.table(countByType(document));
You will likely be surprised by how many nodes a typical web page contains, especially the number of text nodes.
Practical Example: Walking the DOM Tree
Here is a complete example that traverses the DOM tree and builds a visual representation, reinforcing all the concepts covered in this guide:
<!DOCTYPE html>
<html>
<head>
<title>DOM Tree Walker</title>
</head>
<body>
<!-- Main content -->
<div id="content">
<h1>Title</h1>
<p>A <strong>bold</strong> paragraph.</p>
</div>
<script>
function printDOMTree(node, indent = "") {
let line = indent;
switch (node.nodeType) {
case Node.ELEMENT_NODE:
line += `<${node.tagName.toLowerCase()}>`;
break;
case Node.TEXT_NODE:
let text = node.data.trim();
if (text) {
line += `#text: "${text}"`;
} else {
line += `#text: (whitespace)`;
}
break;
case Node.COMMENT_NODE:
line += `<!-- ${node.data.trim()} -->`;
break;
case Node.DOCUMENT_NODE:
line += "#document";
break;
case Node.DOCUMENT_TYPE_NODE:
line += `<!DOCTYPE ${node.name}>`;
break;
default:
line += `[nodeType=${node.nodeType}]`;
}
console.log(line);
// Recurse into children
for (let child of node.childNodes) {
printDOMTree(child, indent + " ");
}
}
printDOMTree(document);
</script>
</body>
</html>
Running this produces output like:
#document
<!DOCTYPE html>
<html>
<head>
#text: (whitespace)
<title>
#text: "DOM Tree Walker"
#text: (whitespace)
#text: (whitespace)
<body>
#text: (whitespace)
<!-- Main content -->
#text: (whitespace)
<div>
#text: (whitespace)
<h1>
#text: "Title"
#text: (whitespace)
<p>
#text: "A"
<strong>
#text: "bold"
#text: "paragraph."
#text: (whitespace)
#text: (whitespace)
<script>
#text: "function printDOMTree(node, indent = "") { ..."
#text: (whitespace)
This output reveals the true shape of the DOM tree, including all the whitespace text nodes that are invisible on the rendered page but very much present in the tree.
Summary
The DOM tree is the browser's in-memory representation of an HTML document. Understanding its structure is the foundation for all DOM manipulation in JavaScript.
Key takeaways:
- The browser parses HTML and builds a tree of nodes in memory.
- Element nodes represent tags, text nodes hold text content, comment nodes hold comments, and the document node is the root.
- Everything in the HTML source becomes a node, including whitespace between tags.
- Whitespace text nodes are a common source of confusion. Use
children,firstElementChild, andnextElementSiblingto skip them. - The browser auto-corrects malformed HTML: it adds missing tags (
<html>,<head>,<body>,<tbody>), closes unclosed tags, and moves misplaced content. - The DOM you see in DevTools is the live DOM tree after auto-correction, not your original HTML source.
- Always inspect the actual DOM with DevTools when debugging, rather than assuming it matches your HTML source.