AniUI Academy

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.

9 min read

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 ancestor

closest() 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.

Try it yourself
Loading playground...

What to remember

  • querySelectorAll and 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 DocumentFragment before 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

up to 50
  1. 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. 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. 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. 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