Lesson 19 of 30
The DOM Is a Tree
Every DOM API you already use is a tree traversal wearing a familiar name — querySelectorAll, closest(), contains() — and knowing which to reach for is a real complexity decision.
Every lesson in this course's tree section has been building toward a specific, concrete payoff: the DOM your code runs against every single day is a tree, and the browser APIs you already use are DFS and BFS wearing different names. Recognizing that isn't trivia — it turns "which DOM method should I use here" into a complexity question you can actually reason about, instead of a guess.
querySelectorAll is depth-first search, in document order
<div id="root">
<section class="item">A</section>
<section>
<span class="item">B</span>
</section>
<section class="item">C</section>
</div>document.querySelectorAll(".item"); // returns A, B, C — in document order"Document order" is the result of a preorder, depth-first traversal:
visit a node, then recurse into its children left to right, before moving to
the next sibling. That's structurally identical to the dfs function from
the previous lesson — the browser is doing exactly that walk internally to
produce the match list.
The complexity consequence is real: querySelectorAll (and getElementsByClassName,
and friends) is O(n) in the number of nodes it has to check within the
searched subtree — every candidate node needs to be tested against the
selector, regardless of how many actually match. Calling it repeatedly
inside a loop (items.forEach(item => item.querySelectorAll(...))) is the
exact "linear operation called inside another loop" trap from the hashing
part of this course, just wearing DOM clothing instead of array clothing.
closest() walks up — one path, not the whole tree
const item = document.querySelector(".item");
item.closest(".dropdown"); // walks UP toward the root, checking each ancestorclosest() doesn't search the tree — it walks a single path, from the
element straight up to the root, checking each ancestor against the
selector and stopping at the first match (or returning null if it reaches
the root without one). That makes it O(d), where d is the element's
depth — bounded by how deeply nested the element is, not by how large the
whole document is. In a large, wide document where most elements sit only a
few levels deep, this is meaningfully cheaper than a full-tree search would
be, precisely because it only ever looks at one ancestor chain.
function closestBy(element, matches) {
let current = element;
while (current !== null) {
if (matches(current)) return current;
current = current.parentElement;
}
return null;
}Written out by hand like this, it's obviously the same shape as walking up a linked list (the next part of this course) — one step at a time, no branching, no need to consider siblings or other subtrees at all.
Where this goes wrong: an O(d) call inside an O(n) loop
Here's the trap, and it's a real one:
// For every item in a long list, find which dropdown it belongs to.
function findDropdownsSlow(items) {
return items.map((item) => item.closest(".dropdown"));
}closest() alone is O(d) — cheap. But calling it once per item, for n
items, each roughly at depth d, costs O(n * d) total — the same
"cheap thing, called repeatedly" pattern as .includes() inside a loop,
just measured in DOM depth instead of array length. The fix mirrors the fix
from the hashing lessons: don't re-derive the same relationship repeatedly —
either query downward from the dropdown once (dropdown.querySelectorAll(".item"),
a single O(subtree size) call), or, in a component-based codebase, pass the
relevant reference down through props or context instead of re-discovering
it from the DOM for every item.
contains() and batched DOM writes
parent.contains(node) — "is node anywhere in parent's subtree" — is
itself a tree-membership check, roughly a DFS looking for one specific node,
so it's O(size of the subtree) in the worst case.
And the classic DOM performance advice — build new nodes into a
DocumentFragment first, then append the fragment once, rather than
appending each new node directly to a live, on-screen parent one at a time —
is this course's "batch the work instead of paying a per-operation cost
repeatedly" theme, applied to layout instead of arrays: appending directly
to a rendered parent can force the browser to account for each individual
insertion, while attaching a fully-built fragment costs one insertion into
the live tree, regardless of how many nodes it contains.
What to remember
querySelectorAlland friends perform a depth-first, preorder traversal — document order — and cost O(n) in the size of the searched subtree.closest()walks a single ancestor path upward and costs O(d), bounded by the element's depth, not the whole document's size.- Calling an O(d) DOM method once per item in an O(n) loop produces O(n * d)
total — the same repeated-linear-operation trap as
.includes()in a loop, fixed the same way: query downward once, or pass the reference down instead of re-deriving it. - Batching DOM writes into a
DocumentFragmentbefore one final append avoids paying a potential layout cost once per node instead of once total.
Check yourself
4 questions · pass 3/4 to unlock Flattening Nested Comments and Building a File Tree
1.What traversal order does document.querySelectorAll('.item') use internally to find matches, and what is its time complexity for a document with n total nodes?
2.element.closest(selector) walks UP the DOM tree from the element toward the root, checking each ancestor. What is its time complexity in terms of the element's depth d in the tree?
3.A component recursively calls element.closest('.dropdown') inside a loop over every item in a long dropdown list, to find which dropdown each item belongs to. For n list items each at roughly the same depth d, what is the total complexity, and what would be a better approach?
4.Why is it more efficient to build a document fragment with all new child nodes attached, then append the fragment once to a live DOM parent — rather than appending each new child directly to the live parent one at a time in a loop?
4 left to answer