Skip to main content

How to Walk the DOM Tree in JavaScript

Once the browser builds the DOM tree from your HTML, JavaScript can navigate it. Every node in the tree is connected to its parent, children, and siblings through a set of built-in properties. By using these properties, you can move from any node to any other node in the document without relying on search methods like querySelector.

This navigation technique is called walking the DOM. It is the foundation for understanding how DOM traversal works internally, and it becomes essential when you need to move relative to a known element rather than searching the entire document.

This guide covers every navigation property available: from the top-level entry points (document.documentElement, document.head, document.body) to parent, child, and sibling relationships. You will also learn the critical difference between node-level navigation (which includes text and comment nodes) and element-only navigation (which skips them), how DOM collections behave, and the special shortcut properties available for HTML tables.

Top-Level Entry Points

The document object provides direct access to the three most important elements of any HTML page. These are your starting points for DOM navigation.

// The <html> element (the root element of the page)
console.log(document.documentElement); // <html>...</html>

// The <head> element
console.log(document.head); // <head>...</head>

// The <body> element
console.log(document.body); // <body>...</body>

These three properties always exist on any valid HTML page:

PropertyReturnsAlways exists?
document.documentElementThe <html> elementYes
document.headThe <head> elementYes
document.bodyThe <body> elementAlmost always
warning

There is one important catch with document.body. If a <script> tag is placed inside <head>, and that script runs before the browser has parsed the <body> tag, then document.body will be null at that moment.

<!DOCTYPE html>
<html>
<head>
<script>
// This runs BEFORE <body> is parsed
console.log(document.body); // null!
</script>
</head>
<body>
<p>Hello</p>
</body>
</html>

The browser builds the DOM top to bottom. A script cannot access elements that have not been parsed yet. This is why placing scripts at the end of <body> or using defer is recommended.

From these entry points, you can navigate to any other node using parent, child, and sibling properties.

Every node in the DOM (except the root document node) has a parent. Two properties let you access it.

parentNode

Returns the parent of any node, regardless of its type. The parent of the <html> element is the document node itself.

let body = document.body;

console.log(body.parentNode); // <html> element
console.log(body.parentNode.parentNode); // #document

let html = document.documentElement;
console.log(html.parentNode); // #document
console.log(document.parentNode); // null (document has no parent)

parentElement

Returns the parent only if it is an element node. The difference matters at the very top of the tree.

let html = document.documentElement;

console.log(html.parentNode); // #document (the document node)
console.log(html.parentElement); // null (document is NOT an element)

The document node has nodeType of 9 (not 1), so it is not an element. That is why parentElement returns null when called on the <html> element.

For most practical purposes, parentNode and parentElement return the same thing. The difference only surfaces at the very top of the tree:

let p = document.querySelector("p");

// For any element inside <body>, these are identical:
console.log(p.parentNode === p.parentElement); // true

Climbing the Tree

You can chain parentNode or parentElement calls to walk up the DOM tree:

let deepElement = document.querySelector("table td span");

// Walk up to the root
let current = deepElement;
while (current) {
console.log(current.nodeName);
current = current.parentElement;
}
// Output: SPAN → TD → TR → TBODY → TABLE → BODY → HTML
// (stops when parentElement returns null)

Child navigation lets you access the nodes nested directly inside a parent.

childNodes

Returns all child nodes of an element as a NodeList, including text nodes, comment nodes, and element nodes.

<body>
<!-- greeting -->
<p>Hello</p>
<p>World</p>
</body>
let body = document.body;
console.log(body.childNodes);
// NodeList(7) [#text, #comment, #text, <p>, #text, <p>, #text]

console.log(body.childNodes.length); // 7

for (let node of body.childNodes) {
console.log(node.nodeType, node.nodeName);
}
// 3 "#text" → whitespace "\n "
// 8 "#comment" → " greeting "
// 3 "#text" → whitespace "\n "
// 1 "P" → <p>Hello</p>
// 3 "#text" → whitespace "\n "
// 1 "P" → <p>World</p>
// 3 "#text" → whitespace "\n"

As you can see, childNodes contains everything: whitespace text nodes, comments, and element nodes.

firstChild and lastChild

These provide quick access to the first and last child nodes:

let body = document.body;

console.log(body.firstChild); // #text (whitespace before the comment)
console.log(body.lastChild); // #text (whitespace after the last <p>)

// They are shortcuts for:
console.log(body.childNodes[0]); // same as firstChild
console.log(body.childNodes[body.childNodes.length - 1]); // same as lastChild

Checking if a Node Has Children

There is a convenient method to check whether a node has any children at all:

let body = document.body;
let img = document.querySelector("img");

console.log(body.hasChildNodes()); // true
console.log(img.hasChildNodes()); // false (self-closing elements have no children)

// You can also check:
console.log(body.childNodes.length > 0); // true
console.log(body.firstChild !== null); // true

Important: childNodes Is Not a True Array

The childNodes property returns a NodeList, which is an array-like object. It has length and supports for...of iteration, but it does not have array methods like map, filter, or forEach (though modern NodeList does have forEach).

let nodes = document.body.childNodes;

// ✅ These work:
console.log(nodes.length);
console.log(nodes[0]);

for (let node of nodes) {
console.log(node.nodeName);
}

nodes.forEach(node => console.log(node.nodeName)); // ✅ NodeList supports forEach

// ❌ These do NOT work:
// nodes.map(n => n.nodeName); // TypeError: nodes.map is not a function
// nodes.filter(n => n.nodeType === 1); // TypeError

// ✅ Convert to a real array first:
let arr = Array.from(nodes);
let elementNames = arr
.filter(n => n.nodeType === 1)
.map(n => n.nodeName);
console.log(elementNames); // ["P", "P"]

Siblings are nodes that share the same parent. Two properties let you move horizontally through the tree.

nextSibling and previousSibling

These return the next and previous node at the same level, including text and comment nodes.

<body>
<p id="first">First</p>
<p id="second">Second</p>
<p id="third">Third</p>
</body>
let first = document.getElementById("first");

// What comes after <p id="first">?
console.log(first.nextSibling); // #text (whitespace "\n ")

// Not the next <p>! It's a whitespace text node.
console.log(first.nextSibling.nextSibling); // <p id="second">

// Going backward from the second <p>:
let second = document.getElementById("second");
console.log(second.previousSibling); // #text (whitespace)
console.log(second.previousSibling.previousSibling); // <p id="first">

The first child's previousSibling and the last child's nextSibling are null:

let body = document.body;
console.log(body.firstChild.previousSibling); // null
console.log(body.lastChild.nextSibling); // null

Sibling and Parent Relationship

There is a consistent relationship between siblings and their parent:

let parent = document.body;
let child = parent.firstChild;

// A node's parent's first child loops back:
console.log(parent.firstChild.parentNode === parent); // true

// Siblings share the same parent:
let a = parent.childNodes[0];
let b = parent.childNodes[1];
console.log(a.parentNode === b.parentNode); // true

Element-Only Navigation

As you have seen, childNodes, firstChild, lastChild, nextSibling, and previousSibling all include every type of node: elements, text nodes, comments, and others. In practice, you almost always want to work with element nodes only, skipping whitespace text and comments.

The DOM provides a parallel set of navigation properties that only return element nodes.

children

Returns only element children, as a live HTMLCollection:

<ul id="list">
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
</ul>
let ul = document.getElementById("list");

// childNodes includes whitespace text nodes
console.log(ul.childNodes.length); // 7 (3 <li> + 4 text nodes)

// children includes only elements
console.log(ul.children.length); // 3 (just the <li> elements)
console.log(ul.children[0].textContent); // "Apple"
console.log(ul.children[1].textContent); // "Banana"
console.log(ul.children[2].textContent); // "Cherry"

firstElementChild and lastElementChild

let ul = document.getElementById("list");

// firstChild is a whitespace text node
console.log(ul.firstChild.nodeName); // "#text"

// firstElementChild is the first <li>
console.log(ul.firstElementChild.textContent); // "Apple"

// lastElementChild is the last <li>
console.log(ul.lastElementChild.textContent); // "Cherry"

nextElementSibling and previousElementSibling

let firstLi = ul.firstElementChild;                                     // <li>Apple</li>

console.log(firstLi.nextElementSibling.textContent); // "Banana"
console.log(firstLi.nextElementSibling.nextElementSibling.textContent); // "Cherry"

let lastLi = ul.lastElementChild; // <li>Cherry</li>
console.log(lastLi.previousElementSibling.textContent); // "Banana"

// Boundaries return null
console.log(firstLi.previousElementSibling); // null (no element before the first)
console.log(lastLi.nextElementSibling); // null (no element after the last)

Complete Comparison Table

All Nodes (includes text, comments)Elements Only
parentNodeparentElement
childNodeschildren
firstChildfirstElementChild
lastChildlastElementChild
nextSiblingnextElementSibling
previousSiblingpreviousElementSibling
hasChildNodes()childElementCount / children.length

When to Use Which

Use element-only properties in the vast majority of cases. They skip whitespace and comment nodes, which is almost always what you want.

Use all-node properties (childNodes, firstChild, etc.) only when you specifically need to access or process text nodes or comment nodes.

// Typical use case: iterate over element children
let nav = document.querySelector("nav");
for (let link of nav.children) {
link.classList.add("nav-link");
}

// Rare use case: process text nodes for a text editor
let paragraph = document.querySelector("p");
for (let node of paragraph.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
node.data = node.data.toUpperCase();
}
}

DOM Collections: Live HTMLCollection vs. Static NodeList

When you access children or query elements, the DOM returns collections. These collections come in two flavors, and understanding the difference is important to avoid subtle bugs.

Live Collections

A live collection automatically updates when the DOM changes. The children property and methods like getElementsByTagName and getElementsByClassName return live collections (HTMLCollection).

<ul id="list">
<li>Apple</li>
<li>Banana</li>
</ul>
let ul = document.getElementById("list");
let items = ul.children; // Live HTMLCollection

console.log(items.length); // 2

// Add a new <li>
let newLi = document.createElement("li");
newLi.textContent = "Cherry";
ul.appendChild(newLi);

// The collection updates automatically!
console.log(items.length); // 3
console.log(items[2].textContent); // "Cherry"

The childNodes property also returns a live NodeList:

let nodes = ul.childNodes; // Live NodeList
console.log(nodes.length); // 7 (3 elements + 4 whitespace text nodes)

ul.appendChild(document.createElement("li"));
console.log(nodes.length); // 9 (4 elements + 5 text nodes including new whitespace)

Static Collections

A static collection is a snapshot of the DOM at the moment it was created. It does not update when the DOM changes. The querySelectorAll method returns a static NodeList.

let ul = document.getElementById("list");
let items = ul.querySelectorAll("li"); // Static NodeList

console.log(items.length); // 3

// Add another <li>
let newLi = document.createElement("li");
newLi.textContent = "Date";
ul.appendChild(newLi);

// The static collection does NOT update
console.log(items.length); // Still 3!

// You need to query again to see the new element
let updatedItems = ul.querySelectorAll("li");
console.log(updatedItems.length); // 4

Comparison Table

SourceReturn TypeLive or Static
element.childNodesNodeListLive
element.childrenHTMLCollectionLive
document.getElementsByTagName()HTMLCollectionLive
document.getElementsByClassName()HTMLCollectionLive
document.querySelectorAll()NodeListStatic
document.querySelector()Single Element or nullN/A

The Live Collection Trap

Live collections can cause unexpected behavior in loops when you modify the DOM during iteration:

// DANGEROUS: modifying the DOM while iterating a live collection
let items = document.getElementsByTagName("li");

// Trying to remove all items
for (let i = 0; i < items.length; i++) {
items[i].remove();
}
// This does NOT remove all items!
// After removing items[0], what was items[1] becomes items[0],
// but i is now 1, so it skips the new items[0].
// SAFE APPROACH 1: Iterate backwards
let items = document.getElementsByTagName("li");
for (let i = items.length - 1; i >= 0; i--) {
items[i].remove();
}
// SAFE APPROACH 2: Convert to static array first
let items = document.getElementsByTagName("li");
let itemsArray = Array.from(items);
itemsArray.forEach(item => item.remove());
// SAFE APPROACH 3: Use querySelectorAll (returns static NodeList)
let items = document.querySelectorAll("li");
items.forEach(item => item.remove());
tip

In modern JavaScript, prefer querySelectorAll for most queries. Its static NodeList is predictable and supports forEach directly. Use live collections only when you specifically need the auto-updating behavior.

Iterating Over Collections

Both NodeList and HTMLCollection support for...of, but only NodeList supports forEach:

let children = document.body.children; // HTMLCollection

// ✅ for...of works on both
for (let child of children) {
console.log(child.tagName);
}

// ❌ forEach does NOT work on HTMLCollection
// children.forEach(child => console.log(child.tagName)); // TypeError!

// ✅ Convert to array for full array methods
Array.from(children).forEach(child => console.log(child.tagName));

// ✅ forEach works on NodeList (from querySelectorAll)
document.querySelectorAll("div").forEach(div => console.log(div.tagName));

Special Navigation for Tables

HTML tables are so commonly manipulated with JavaScript that the DOM provides dedicated shortcut properties. These let you navigate table structures without manually walking through <tbody>, <tr>, and <td> elements.

Table Element Properties

The <table> element provides:

let table = document.querySelector("table");

// All <tbody> elements (there can be multiple)
console.log(table.tBodies); // HTMLCollection of <tbody> elements

// The <thead> element (or null)
console.log(table.tHead);

// The <tfoot> element (or null)
console.log(table.tFoot);

// ALL rows in the table, from all sections (thead, tbody, tfoot)
console.log(table.rows); // HTMLCollection of <tr> elements

// The <caption> element (or null)
console.log(table.caption);

Thead, Tbody, Tfoot Properties

Each table section (<thead>, <tbody>, <tfoot>) provides:

let tbody = table.tBodies[0];

// Rows within this section only
console.log(tbody.rows); // HTMLCollection of <tr> in this <tbody>
console.log(tbody.rows.length);

Table Row Properties

Each <tr> element provides:

let row = table.rows[0];

// All cells in this row (<td> and <th>)
console.log(row.cells); // HTMLCollection of <td>/<th>

// The index of this row within the table
console.log(row.rowIndex); // 0 (index among ALL table rows)

// The index within its section (thead, tbody, or tfoot)
console.log(row.sectionRowIndex); // 0 (index within its section)

Table Cell Properties

Each <td> or <th> element provides:

let cell = table.rows[0].cells[0];

// The index of this cell within its row
console.log(cell.cellIndex); // 0

Complete Table Navigation Example

<table id="scores">
<thead>
<tr>
<th>Name</th>
<th>Score</th>
<th>Grade</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>95</td>
<td>A</td>
</tr>
<tr>
<td>Bob</td>
<td>82</td>
<td>B</td>
</tr>
<tr>
<td>Charlie</td>
<td>91</td>
<td>A</td>
</tr>
</tbody>
</table>
let table = document.getElementById("scores");

// Access the header row
let headerRow = table.tHead.rows[0];
let headers = Array.from(headerRow.cells).map(cell => cell.textContent);
console.log(headers); // ["Name", "Score", "Grade"]

// Access all body rows
let tbody = table.tBodies[0];
for (let row of tbody.rows) {
let name = row.cells[0].textContent;
let score = parseInt(row.cells[1].textContent);
let grade = row.cells[2].textContent;
console.log(`${name}: ${score} (${grade})`);
}
// "Alice: 95 (A)"
// "Bob: 82 (B)"
// "Charlie: 91 (A)"

// Find all students with grade "A"
let topStudents = [];
for (let row of tbody.rows) {
if (row.cells[2].textContent === "A") {
topStudents.push(row.cells[0].textContent);
}
}
console.log("Top students:", topStudents); // ["Alice", "Charlie"]

// Get a specific cell by row and column index
console.log(table.rows[2].cells[1].textContent); // "82" (Bob's score)

// Highlight every other row
for (let i = 0; i < tbody.rows.length; i++) {
if (i % 2 === 1) {
tbody.rows[i].style.backgroundColor = "#f0f0f0";
}
}
info

Remember that the browser automatically inserts <tbody> even if you do not write it in your HTML. This means table.rows[0] might give you the header row (from <thead>), while table.tBodies[0].rows[0] gives you the first data row. Always be explicit about which section you are accessing.

Without Table Properties vs. With Table Properties

Compare the verbosity of navigating a table with generic DOM methods versus the specialized table properties:

// WITHOUT table properties (verbose and fragile)
let table = document.getElementById("scores");
let tbody = table.children[1]; // Hope it's <tbody> and not <thead>
let secondRow = tbody.children[1]; // Hope no whitespace nodes interfere
let thirdCell = secondRow.children[2];
console.log(thirdCell.textContent);

// WITH table properties (clean and reliable)
let table2 = document.getElementById("scores");
let grade = table2.tBodies[0].rows[1].cells[2].textContent;
console.log(grade); // "B"

Practical Example: DOM Navigation Utility

Here is a utility function that demonstrates various navigation techniques. Given any element, it reports its position in the DOM tree:

function describePosition(element) {
let info = {};

// Identity
info.tagName = element.tagName;
info.id = element.id || "(none)";

// Parent
info.parent = element.parentElement
? element.parentElement.tagName
: "none";

// Children
info.childElementCount = element.children.length;
info.childNodeCount = element.childNodes.length;
info.childTags = Array.from(element.children).map(c => c.tagName);

// Siblings
info.previousElement = element.previousElementSibling
? element.previousElementSibling.tagName
: "none";
info.nextElement = element.nextElementSibling
? element.nextElementSibling.tagName
: "none";

// Depth from <body>
let depth = 0;
let current = element;
while (current && current !== document.body) {
depth++;
current = current.parentElement;
}
info.depthFromBody = depth;

// Index among siblings
let index = 0;
let sibling = element.previousElementSibling;
while (sibling) {
index++;
sibling = sibling.previousElementSibling;
}
info.indexAmongSiblings = index;

return info;
}

// Usage
let target = document.querySelector("p");
console.table(describePosition(target));

Here is another example that collects all text content from a subtree, walking only through text nodes:

function getAllText(node) {
let result = "";

if (node.nodeType === Node.TEXT_NODE) {
result += node.data;
}

for (let child of node.childNodes) {
result += getAllText(child);
}

return result;
}

// This is essentially what .textContent does internally
let bodyText = getAllText(document.body);
console.log(bodyText.trim());

Summary

Walking the DOM means navigating from one node to another using built-in properties rather than search methods. Here is the complete reference:

Top-Level Access:

  • document.documentElement (the <html> element)
  • document.head (the <head> element)
  • document.body (the <body> element, can be null if accessed too early)

All-Node Navigation (includes text, comments, and element nodes):

DirectionProperty
ParentparentNode
ChildrenchildNodes, firstChild, lastChild
SiblingsnextSibling, previousSibling
Has children?hasChildNodes()

Element-Only Navigation (skips text and comment nodes):

DirectionProperty
ParentparentElement
Childrenchildren, firstElementChild, lastElementChild
SiblingsnextElementSibling, previousElementSibling
CountchildElementCount

DOM Collections:

  • childNodes returns a live NodeList
  • children returns a live HTMLCollection
  • querySelectorAll returns a static NodeList
  • Live collections update automatically when the DOM changes. Be careful modifying the DOM while iterating over them.

Table Shortcuts:

  • table.rows, table.tBodies, table.tHead, table.tFoot
  • tbody.rows (rows within a section)
  • tr.cells, tr.rowIndex, tr.sectionRowIndex
  • td.cellIndex

Use element-only properties by default. Switch to all-node properties only when you specifically need to work with text or comment nodes.