JavaScript DOM Traversal Table

Property / methodGives youField note
parentNodeThe containing elementMay be a document or fragment, not just an element
parentElementThe containing ELEMENTnull when the parent is the document - the practical pick
childrenElement children onlySkips text nodes - unlike childNodes
childNodesALL child nodesIncludes whitespace TEXT nodes - the classic surprise
firstElementChildFirst element childBeats firstChild (often a text node)
nextElementSiblingNext element siblingElement-aware; nextSibling stops at whitespace
closest('.sel')Nearest ancestor matchingWalks UP including self - event delegation's partner
matches('.sel')Self test against selectorThe filter half of delegation
contains(node)Descendant testTrue for the element itself too - guard with a !== b
remove()Remove selfModern; replaces the parent.removeChild(child) dance
Reference: the MDN Node API reference. The one distinction behind most DOM bugs: the DOM tree contains ELEMENT nodes and TEXT nodes, and the older properties (childNodes, firstChild, nextSibling) count the whitespace text nodes between tags while the Element variants (children, firstElementChild, nextElementSibling) skip them. Bottom line: default to the Element variants, use closest() for delegation, and remember remove() - the one-liner that replaced a two-step dance. Related tools: array methods table for iterating children, and URL API table for the parameters that decide what to render.

DOM traversal is walking the element tree from JavaScript: up to parents, across to siblings, down to children, with tests along the way. The table below is the working ten - and the single distinction behind most traversal bugs: the DOM contains ELEMENT nodes and TEXT nodes, and the older properties count both while the Element-suffixed ones skip the text.

Bottom line: default to the Element variants. childNodes includes the whitespace text nodes BETWEEN your tags (the newlines and indents in formatted HTML become nodes), which is why firstChild is often a text node and children exists. Same for siblings: nextSibling stops at whitespace; nextElementSibling is the one you meant.

The honest part: two methods changed how traversal code is written. closest('.selector') walks up from an element to the nearest ancestor matching a selector - the partner of event delegation, where one parent listener answers for many children by asking event.target.closest('button'). And remove() replaced the parent.removeChild(child) two-step: an element now removes itself.

How to use

  1. Pick the Element variant by default: children, firstElementChild, nextElementSibling - they skip the whitespace nodes.
  2. Use closest() plus matches() for delegation: one listener on the container, event.target.closest('button') finds the actionable ancestor.
  3. Remember remove() for deletion - and contains() (which counts the element itself) when testing containment.

Frequently asked questions

Why does firstChild give me a text node instead of my element?

Because the DOM counts whitespace: the newline and indent between <ul> and <li> in formatted HTML is a real text node, and it is the first child. firstChild returns it; firstElementChild skips text and comment nodes to give the first actual element. The same pair exists for siblings (nextSibling vs nextElementSibling) and for the child list (childNodes vs children). Default to the Element variants and the whitespace surprise disappears.

What is the difference between parentNode and parentElement?

Almost nothing, by design: both return the containing node, except when the parent is the Document or a DocumentFragment - parentElement returns null there, parentNode returns the node. In practice you want parentElement: the cases where the parent is not an element (the document itself) are exactly the cases where element-specific methods would fail next anyway.

How does closest() work with event delegation?

closest('.selector') walks UP from the element - starting with itself - to the nearest ancestor matching the selector. Combined with one listener on a container, it is event delegation: container.addEventListener('click', e => { const btn = e.target.closest('button.action'); if (!btn) return; handle(btn); }). The pattern survives dynamic content (new children need no new listeners) and matches() is its filtering half - testing one element against a selector without walking.

Does contains() mean what it looks like?

Almost: node.contains(other) returns true when other is INSIDE node - but also when other IS node itself. For a strict 'is a proper descendant' test, add the identity guard: node !== other && node.contains(other). The same self-inclusion applies to CSS :has and to closest() starting at self - DOM containment APIs consistently count the element itself, which is either convenient or a bug depending on the question you meant to ask.

Related tools