DOMGENERALENGINE-SPECIFIC

Queries, Live Collections and Stale References

Some queries return a snapshot, some return a view that keeps changing under you, and some are not questions about the tree at all — they force layout to answer.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When I ask the DOM for a set of elements, what exactly did I get — and will it still be true in a moment?

The user intent

A person interacts with something on the page. The code handling that interaction needs to find the elements involved and act on them.

The obvious build

Query for the elements you need, keep the result in a variable, and use it. A list of elements is a list of elements.

Why it breaks

getElementsByTagName returns a live HTMLCollection. A loop that appends matching elements while reading .length never terminates, and one that removes them while incrementing an index skips every other element.

How it breaks in a real browser
  • getElementsByTagName returns a live HTMLCollection. A loop that appends matching elements while reading .length never terminates, and one that removes them while incrementing an index skips every other element.
  • querySelectorAll returns a static NodeList. It is a snapshot, so after a re-render it holds references to nodes that are no longer in the document — and mutating them silently does nothing visible (Node Identity Across Updates).
  • el.childNodes includes text and comment nodes. The whitespace and newlines in your own markup are nodes, which is why childNodes[0] is so often not the element you meant.
  • getElementById on a component rendered twice returns the first match, and so does every aria-labelledby reference to that id. Nothing warns you (The Rules of ARIA).
  • getBoundingClientRect looks like a query about the tree and is a query about layout — it can force the browser to compute layout synchronously to answer it (Layout Thrashing).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Live collectionsgetElementsByTagName, getElementsByClassName, getElementsByName, el.children, document.forms, document.images — are views over the tree. They are cheap to obtain because they evaluate nothing up front; the cost is paid when you read .length or an index, and the answer reflects the tree at that instant.
  • Static collectionsquerySelectorAll, and childNodes in the sense that its membership is live but it is a NodeList — are materialised at call time. querySelectorAll walks the subtree, evaluates the full selector against each candidate and builds an array-like of the matches.
  • Selector matching runs right to left. #app .row span finds every span, then walks ancestors checking for .row and #app. That is why the rightmost part of a selector determines the candidate set, and why leaf-heavy selectors are the expensive ones (Selector Matching Cost).
  • closest() walks from an element up through its ancestors, and matches() tests one element against a selector. Together they are the primitive that makes event delegation possible from a single listener on a container (Event Delegation).
  • A query returns references to the existing node objects, never copies. Holding one keeps that node — and therefore its entire subtree, through parent and sibling pointers — reachable (Detached Nodes and What Keeps Them Alive).
  • Geometry accessors are a different category entirely. getBoundingClientRect, offsetTop, scrollHeight and getComputedStyle().width are questions the browser can only answer by making layout current.

What this makes the browser do

And which of it is avoidable.

  • Walking the subtree and evaluating the selector per candidate for querySelectorAll. Cost scales with subtree size and selector complexity, not with the number of matches.
  • Maintaining and invalidating cached results for live collections as the tree changes. Engines cache aggressively, and a mutation in a loop defeats that cache.
  • Running style and layout to completion when a geometry accessor is read while they are dirty — the one case where a "read" is the most expensive line in the function.
  • Avoidable: repeating the same document-wide query on every event, and querying inside a loop that also mutates.

What each query actually returns

The API surface here grew over three decades, and it shows: two collection types, two liveness semantics, and one family of accessors that are not tree queries at all. The table is worth learning once because the failure modes it explains are otherwise indistinguishable from magic.

The last row is the one people are most surprised by. getBoundingClientRect sits in the same object as the tree queries and behaves nothing like them — it is a request for a value that only layout can produce (What a Mutation Costs).

APIReturnsLive?Cost shapeWhere it bites
getElementByIdElement or nulln/aHash lookup — the cheapest query there isDuplicate ids return the first, and break every id-based ARIA reference
getElementsByTagName / ByClassNameHTMLCollectionLiveFree to obtain; cost on each read.length re-evaluates every iteration — the infinite-loop classic
querySelectorFirst match or nulln/aWalks in document order, stops at the first hitSilently returns null; a chained property access then throws far from the cause
querySelectorAllNodeList (static)NoWalks the subtree, matches the full selector, materialisesA snapshot that goes stale the moment anything re-renders
el.childrenHTMLCollectionLiveFree to obtainElements only — misleadingly convenient next to childNodes
el.childNodesNodeListLiveFree to obtainIncludes text and comment nodes; your own indentation is in there
closest / matchesElement or booleann/aAncestor walk / single testThe delegation primitive — cost scales with tree depth, not width
getBoundingClientRect, offsetTopGeometryn/aMay force synchronous layoutNot a tree query at all; catastrophic inside a write loop

The loop that never ends

Both bugs below come from the same root cause and have opposite symptoms, which is why they are worth seeing together. A live collection is a standing query; every read of .length or of an index re-asks the question of a tree you have been changing.

Neither is a subtle mistake in the sense of being hard to write — they are subtle in the sense that the code reads correctly. for (let i = 0; i < items.length; i++) is the loop everyone has written ten thousand times, and it is only wrong because items is not the array it looks like.

Two failures of the same standing query
1const list = document.querySelector('#todos')
2
3// 1. Never terminates. `rows.length` is re-evaluated every iteration,
4// and every iteration adds another matching element.
5const rows = list.getElementsByTagName('li')
6for (let i = 0; i < rows.length; i++) {
7 list.appendChild(document.createElement('li'))
8}
9
10// 2. Removes every OTHER element. After removing index 0 the collection
11// re-indexes, so what was index 1 is now index 0 — and i became 1.
12for (let i = 0; i < rows.length; i++) {
13 rows[i].remove()
14}
15
16// Fix: materialise once. A real array cannot change under the loop.
17const snapshot = [...list.querySelectorAll('li')]
18for (const row of snapshot) row.remove()
19
20// Or, when a continuously-current view is genuinely what you want,
21// say so and never index into it while mutating:
22const openDialogs = document.getElementsByClassName('dialog--open')
23const anyOpen = () => openDialogs.length > 0 // correct use of liveness

The [...] in the fix is doing the real work: it converts a standing query into a value. Everything else is style.

Stale references and where they come from

A stale reference produces the worst possible failure signature: no error, no visual change, and a node that stays in memory because you are still holding it. The code did exactly what it was told, to an element that is no longer in the document.

The universal check is el.isConnected. If you find yourself needing it in application code regularly, that is a signal that node references are being cached across updates that own them — which is a design problem rather than a bug to patch (Who Owns This State?).

Query bugs by signature
TriggerSymptomCauseResponse
Indexing a live collection while mutatingTab freezes, or every second element is skippedThe collection is a standing query, re-evaluated on every readMaterialise with [...el.querySelectorAll(...)] before iterating.
Using a querySelectorAll result after a re-renderMutation has no visible effect; memory growsThe snapshot references nodes the framework replacedRe-query inside the handler, or hold a ref the framework keeps current (Node Identity Across Updates).
document.querySelector inside a componentWorks with one instance, breaks with twoThe query escaped the component and found another instance's markupScope every query to the component root (Drawing Component Boundaries).
Duplicate ids from a repeated componentClicking one label focuses another instance's inputId references resolve to the first match in document orderGenerate instance-unique ids, or use wrapping labels which need no id at all (Errors People Can Actually Perceive).
A query inside a mousemove or scroll handlerScrolling stutters on mid-range devicesA subtree walk running at input frequency on the main threadHoist the query out of the handler and refresh it on a change, not on every event (Passive Listeners).
A reference captured before awaitIntermittent no-op after a slow responseThe node was replaced while the promise was pendingRe-resolve after the await, or check isConnected and bail (Cancelling a Request Nobody Is Waiting For).
Assuming querySelectorAll reaches into a componentA third-party widget's internals are invisible to your codeA shadow root, which queries do not cross in either directionUse the element's documented API, or ::part for styling (Shadow DOM and the Composed Tree).

How to build it

Most important first.

  • Prefer querySelectorAll and immediately materialise it — [...container.querySelectorAll('li')] — when you are going to mutate while iterating. A real array cannot change under you.
  • Reach for a live collection deliberately, when a continuously-current view is what you actually want, and never as the default because the name is familiar.
  • Scope every query to the smallest subtree that can contain the answer. container.querySelectorAll(...) instead of document.querySelectorAll(...) is both faster and a component boundary you can reason about (Drawing Component Boundaries).
  • Do not cache node references across updates that can replace nodes. Re-query, or key the lookup to something stable, or let the framework own the reference (Reconciliation and Keys).
  • Use one delegated listener plus closest() for repeated rows rather than one listener per row. It survives rows being added and removed, which a per-row listener does not (Event Delegation).
  • Treat geometry reads as a separate phase. Group them, do them before writes, and never put one inside a loop that writes (What a Mutation Costs).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Duplicate ids are an accessibility bug before they are a query bug. for, aria-labelledby, aria-describedby and aria-controls resolve to the *first* element with that id, so a repeated component silently points every instance's label at the first one (The Rules of ARIA).
  • A stale reference held for focus management is worse than no reference. Calling .focus() on a detached node does nothing at all, and focus stays wherever it was — which for a keyboard user is usually the document body (Focus Management).
  • Querying for "the focusable elements" with a hardcoded selector list is the standard focus-trap implementation and it is always incomplete: it misses contenteditable, positive tab indices, elements inside shadow roots, and anything disabled or hidden since the query ran. Re-query at the moment of the trap, not at mount (Accessible Component Patterns).
  • Event delegation must delegate keyboard events too. A click listener on a container catches keyboard activation of real buttons — because the browser fires a synthetic click — but catches nothing at all for a div pretending to be one (Keyboard Events).

What can go wrong

Failure modes
  • An infinite loop from appending inside for (let i = 0; i < live.length; i++), which freezes the tab entirely — the main thread never returns to the event loop (Long Tasks).
  • Removing elements from a live collection by index, which skips every second element because the collection re-indexes after each removal.
  • A cached querySelectorAll result used after a re-render, which mutates detached nodes: no error, no visual change, and the nodes stay alive in memory (Detached Nodes and What Keeps Them Alive).
  • A component querying document and finding another component's markup. It works until the same component is used twice on a page.
  • A query in a scroll, mousemove or resize handler, running at input frequency on the main thread (Passive Listeners).
  • Assuming querySelectorAll sees into a shadow root. It does not, in either direction (Shadow DOM and the Composed Tree).
What can arrive out of order
  • A live collection can change between two reads within the same synchronous block if anything in between mutates the tree — including a layout-triggering read that runs a ResizeObserver callback.
  • A reference captured before an await may be detached by the time the continuation runs, because a re-render happened while the promise was pending (The Life of a Fetch).
Security
  • Queries are not a trust boundary. Any script in the page can query for your form fields, read their values, and register listeners on your elements (Third-Party Scripts and the Supply Chain).
  • Building a selector by concatenating user input is an injection of a different kind: an id or class from untrusted data can break out of the selector and match far more than intended. Use CSS.escape for any interpolated value.
  • DOM clobbering: a form control or element with name="action" or id="config" becomes a property of document and of its parent form, so an attacker who can inject even inert markup can shadow a global your code reads. Never read configuration off window or document by name (Cross-Site Scripting).
  • A closed shadow root prevents querySelector from reaching in, but it is an encapsulation feature and not a security boundary — same-origin script has other routes to the same nodes (Shadow DOM and the Composed Tree).
Misreads
  • "querySelectorAll returns an array." It returns a static NodeList. It has forEach but not map, filter or find, which is why so much code spreads it immediately.
  • "getElementsByClassName is faster than querySelectorAll." Obtaining it is cheaper because it does no work; using it may be more expensive, and correctness under mutation is the thing that actually differs.
  • "A static NodeList means the nodes are copies." The list is a snapshot; the nodes in it are the same live objects, and mutating them mutates the document — if they are still in it.
  • "children and childNodes are the same." children is elements only; childNodes includes every text and comment node, including your indentation.
  • "Caching a query is always an optimisation." Caching a reference across an update that can replace nodes is how you end up mutating a detached tree (Detached Nodes and What Keeps Them Alive).

Measuring it, and what changes in the field

How you would see this
  • The Performance panel attributes selector-matching time to Recalculate Style and query time to the calling script frame; a flame chart makes a query inside a loop unmistakable (Debugging Rendering and Jank).
  • Forced-layout warnings name the exact line whose geometry read triggered synchronous layout.
  • A heap snapshot shows detached nodes still referenced by an array of cached query results (Debugging Memory).
  • In the console, comparing el.isConnected against your cached reference is the one-line check for "is this thing still in the document".
Slow device, slow network, large data, old tab
  • On a large tree, document.querySelectorAll walks everything. The same call scoped to a container is unchanged in cost as the rest of the page grows.
  • On a slow device, a query in a scroll handler is the difference between smooth scrolling and dropped frames, because it lands inside a frame that already has work to do (Scroll and Input Latency).
  • With a virtualised list, cached references become stale constantly by design — rows are recycled, so a reference to "row 12" may now be showing row 400 (List Virtualization).
  • Across a re-render, whether references survive is entirely a question of identity, and identity is a framework-level decision (Node Identity Across Updates).
What this costs
  • Materialising a query into an array costs an allocation and a snapshot that can go stale. It buys iteration you can reason about, which is almost always the better trade — but "almost" is doing work in that sentence for very hot code.
  • Scoping queries to a container requires that the container reference itself be current, which moves the staleness problem up one level rather than removing it.
  • Event delegation trades a small amount of per-event work — the ancestor walk — for listeners that do not need to be attached or removed as rows change. On very deep trees with very frequent events, that walk is not free (Event Delegation).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALWhich APIs return live collections and which return static ones is specified, not implementation-defined, and is identical across Blink, Gecko and WebKit. Code that depends on liveness is portable; code that assumes staticness from a live API is broken everywhere equally.
  • ENGINE-SPECIFICThe relative cost of the query APIs is not specified. Engines cache live-collection results and index selector matching differently, so a microbenchmark showing one API faster in Chromium frequently reverses in Gecko. Choose on correctness under mutation, not on benchmark results (Microbenchmark or End-to-End: Why p99 Did Not Move in Performance).

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — a live collection is a lazily-evaluated view, and the caching and invalidation an engine does for it is the same problem a query planner solves.