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.
When I ask the DOM for a set of elements, what exactly did I get — and will it still be true in a moment?
A person interacts with something on the page. The code handling that interaction needs to find the elements involved and act on them.
Query for the elements you need, keep the result in a variable, and use it. A list of elements is a list of elements.
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.
getElementsByTagNamereturns a liveHTMLCollection. A loop that appends matching elements while reading.lengthnever terminates, and one that removes them while incrementing an index skips every other element.querySelectorAllreturns a staticNodeList. 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.childNodesincludes text and comment nodes. The whitespace and newlines in your own markup are nodes, which is whychildNodes[0]is so often not the element you meant.getElementByIdon a component rendered twice returns the first match, and so does everyaria-labelledbyreference to that id. Nothing warns you (The Rules of ARIA).getBoundingClientRectlooks 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).
What is actually happening
In the browser, not in the framework.
- Live collections —
getElementsByTagName,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.lengthor an index, and the answer reflects the tree at that instant. - Static collections —
querySelectorAll, andchildNodesin the sense that its membership is live but it is aNodeList— are materialised at call time.querySelectorAllwalks 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 spanfinds everyspan, then walks ancestors checking for.rowand#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, andmatches()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,scrollHeightandgetComputedStyle().widthare 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).
| API | Returns | Live? | Cost shape | Where it bites |
|---|---|---|---|---|
getElementById | Element or null | n/a | Hash lookup — the cheapest query there is | Duplicate ids return the first, and break every id-based ARIA reference |
getElementsByTagName / ByClassName | HTMLCollection | Live | Free to obtain; cost on each read | .length re-evaluates every iteration — the infinite-loop classic |
querySelector | First match or null | n/a | Walks in document order, stops at the first hit | Silently returns null; a chained property access then throws far from the cause |
querySelectorAll | NodeList (static) | No | Walks the subtree, matches the full selector, materialises | A snapshot that goes stale the moment anything re-renders |
el.children | HTMLCollection | Live | Free to obtain | Elements only — misleadingly convenient next to childNodes |
el.childNodes | NodeList | Live | Free to obtain | Includes text and comment nodes; your own indentation is in there |
closest / matches | Element or boolean | n/a | Ancestor walk / single test | The delegation primitive — cost scales with tree depth, not width |
getBoundingClientRect, offsetTop | Geometry | n/a | May force synchronous layout | Not 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.
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 collection11// 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 livenessThe [...] 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?).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Indexing a live collection while mutating | Tab freezes, or every second element is skipped | The collection is a standing query, re-evaluated on every read | Materialise with [...el.querySelectorAll(...)] before iterating. |
Using a querySelectorAll result after a re-render | Mutation has no visible effect; memory grows | The snapshot references nodes the framework replaced | Re-query inside the handler, or hold a ref the framework keeps current (Node Identity Across Updates). |
document.querySelector inside a component | Works with one instance, breaks with two | The query escaped the component and found another instance's markup | Scope every query to the component root (Drawing Component Boundaries). |
| Duplicate ids from a repeated component | Clicking one label focuses another instance's input | Id references resolve to the first match in document order | Generate 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 handler | Scrolling stutters on mid-range devices | A subtree walk running at input frequency on the main thread | Hoist the query out of the handler and refresh it on a change, not on every event (Passive Listeners). |
A reference captured before await | Intermittent no-op after a slow response | The node was replaced while the promise was pending | Re-resolve after the await, or check isConnected and bail (Cancelling a Request Nobody Is Waiting For). |
Assuming querySelectorAll reaches into a component | A third-party widget's internals are invisible to your code | A shadow root, which queries do not cross in either direction | Use the element's documented API, or ::part for styling (Shadow DOM and the Composed Tree). |
How to build it
Most important first.
- Prefer
querySelectorAlland 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 ofdocument.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-describedbyandaria-controlsresolve 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
clicklistener on a container catches keyboard activation of real buttons — because the browser fires a synthetic click — but catches nothing at all for adivpretending to be one (Keyboard Events).
What can go wrong
- 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
querySelectorAllresult 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
documentand finding another component's markup. It works until the same component is used twice on a page. - A query in a
scroll,mousemoveorresizehandler, running at input frequency on the main thread (Passive Listeners). - Assuming
querySelectorAllsees into a shadow root. It does not, in either direction (Shadow DOM and the Composed Tree).
- 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
ResizeObservercallback. - A reference captured before an
awaitmay be detached by the time the continuation runs, because a re-render happened while the promise was pending (The Life of a Fetch).
- 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.escapefor any interpolated value. - DOM clobbering: a form control or element with
name="action"orid="config"becomes a property ofdocumentand of its parent form, so an attacker who can inject even inert markup can shadow a global your code reads. Never read configuration offwindowordocumentby name (Cross-Site Scripting). - A closed shadow root prevents
querySelectorfrom 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).
- "
querySelectorAllreturns an array." It returns a staticNodeList. It hasforEachbut notmap,filterorfind, which is why so much code spreads it immediately. - "
getElementsByClassNameis faster thanquerySelectorAll." 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.
- "
childrenandchildNodesare the same."childrenis elements only;childNodesincludes 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
- 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.isConnectedagainst your cached reference is the one-line check for "is this thing still in the document".
- On a large tree,
document.querySelectorAllwalks 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).
- 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.
- — 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.