Web Platform · DOM and Events

Live Collections, Static Snapshots, and the Cost of Traversal

Query results differ in whether they track later mutations, and that difference decides whether iteration is safe and when a query pays for a full subtree walk.

Two lines of code that look interchangeable return objects with opposite semantics:

const a = document.querySelectorAll('.item');   // static snapshot
const b = document.getElementsByClassName('item'); // live view

Both are array-like, both have length, both are iterable. Remove one matching element and a still reports the old count while b reports the new one. The difference is not a quirk of these two methods; it is a property of the collection interfaces they return, and it changes which loops are correct.

A live collection is a query, evaluated on read

A live HTMLCollection or NodeList holds no result set. Its length and its indexed properties are computed from the tree at the moment they are read, using the filter and root that created it. childNodes is live in this sense: it is a view of the parent’s child pointer chain. So is children, document.forms, document.images, and anything returned by getElementsByTagName or getElementsByClassName.

Engines are free to cache the list and invalidate it when the tree changes; the usual strategy is to bump a version counter on the document or subtree and let the collection recompute on its next access. That is an implementation strategy, not a guarantee. Invalidation is triggered by the same tree changes that produce records for mutation observers, except that a collection recomputes lazily on the next read rather than running a callback. The observable contract is only that reads reflect the current tree, and any code that depends on the caching being cheap is depending on an implementation detail.

The consequence for iteration is the classic skip bug:

const rows = list.children;           // live HTMLCollection
for (let i = 0; i < rows.length; i++) {
  rows[i].remove();                   // length shrinks as the loop runs
}

Each removal shifts the remaining elements down by one index while i advances, so every other row survives. The same code over querySelectorAll removes everything, because the snapshot’s length and contents do not change. Neither behaviour is wrong; they answer different questions. A live collection answers “what matches now”, a snapshot answers “what matched then”.

Appending inside a loop over a live collection is the other failure mode: if the loop condition re-reads length and the body inserts matching elements, the loop does not terminate. Snapshots cannot exhibit this, which is one reason querySelectorAll is the default choice in application code.

Static snapshots and tree order

querySelectorAll walks the subtree at call time and materialises an array-like NodeList in tree order. Tree order is depth-first pre-order: a node precedes its descendants, and siblings follow in document sequence. querySelector returns the first element in that order, which is why it cannot be assumed to return the “closest” match or the most specific one; it returns the earliest in document order.

The traversal starts at the root and considers candidates in tree order, so the cost is bounded by the subtree rather than by the number of matches. A query that matches nothing is not free: querySelectorAll('.missing') over a large document still has to examine the elements it could match. A complex selector adds per-candidate work, but the scan is the dominant term.

Shadow trees are not entered. Selector matching operates within a single tree, so a query on the document does not see elements inside a component’s shadow root, and a query on a shadow root does not see outside it. Slotted content stays in its light tree for selector purposes even though it renders inside the shadow tree. Crossing the boundary requires a query per root, which is what shadowRoot.querySelectorAll is for.

Holding a snapshot holds its nodes

A static NodeList retains strong references to every node it contains. Storing one in a module-level variable after the nodes are removed from the document keeps the whole subtree alive, including its listeners and any data attached to it in closures. The list is not a live view that empties itself; it is an array of objects that happen to have parentNode === null.

The remedy is to convert to a plain array of what is actually needed, or to drop the reference when the region is torn down. Array.from(list) copies the references into a new array, which does not help with lifetime; mapping to identifiers or values does. Live collections have the opposite hazard: they hold a reference to their root and to the filter, but not to the matched nodes, so an unused live collection does not pin a removed subtree in the same way.

Both interfaces are iterable and both support index access, but only NodeList has forEach. That asymmetry is a leftover of their separate histories, and it is why Array.prototype.forEach.call(collection, fn) still appears in older code. Neither has map or filter; both need Array.from for array methods.

Named access is part of the HTMLCollection surface

HTMLCollection supports lookup by name as well as by index, through namedItem(name) and through named properties exposed on the object. document.forms and form.elements rely on this: a form with a control named email can be addressed as form.elements.email as well as by index. The lookup matches an element whose id or name attribute equals the key, and the first match in tree order wins.

The lookup is a tree query, not a hash lookup, and for a live collection it reflects renames immediately. Renaming a control changes which property name resolves to it. Duplicate names are resolved differently by different collections: a form controls collection returns a RadioNodeList containing every match, which is how a radio group exposes its value, while a plain HTMLCollection such as document.forms returns the first element in tree order.

NodeList has no such surface. A static list is index-only, which is the safer default: property access on a NodeList never depends on the names present in the document, so the code does not change behaviour when a new element is introduced with a colliding name.

Sorting, comparing, and the position bits

Collections arrive in tree order, and code that needs a different order must build it. Comparing two nodes directly is the job of compareDocumentPosition, which returns a bitmask rather than a signed number, and which reports DOCUMENT_POSITION_DISCONNECTED when the nodes are in different trees. The mask also carries PRECEDING, FOLLOWING, CONTAINS, and CONTAINED_BY, so the same call answers relationship questions that would otherwise need an ancestor walk. A comparator for a sort must test the disconnected bit before interpreting direction, since the direction of disconnected nodes is explicitly implementation-defined, as noted in node identity and structural mutation.

matches and closest are the single-node counterparts: they test a node or its ancestors without building a collection. closest walks ancestors until a match or the root, in time proportional to depth, and it does not cross shadow boundaries. For hit-testing style code, closest is cheaper than querying a subtree and testing membership, because the subtree query is proportional to the number of descendants while the ancestor walk is proportional to depth.

Choosing between them

The decision hinges on two questions: does the code need to react to later mutations, and does it run in a hot path.

A snapshot is right when the code will mutate the region while iterating, when the result is passed somewhere that must not change underneath it, and when the query runs once per operation. A live collection is right when the caller genuinely wants a view that stays current, such as a cache keyed by matching elements, or when the same query is read repeatedly between mutations and the implementation can reuse its invalidated cache.

For repeated reads inside a mutation-heavy loop, neither is free: a live collection may recompute on every read, and a snapshot costs a traversal at creation. The reliable optimisation is to avoid re-querying inside the loop at all, keeping the single snapshot and indexing into it. The traversal cost belongs to the query, and the way to reduce it is to start from a narrower root rather than to change the collection type.

What mutation does to an in-flight iteration

Iterating with for...of over a NodeList uses an iterator that reads indexed properties up to the current length on each step, so a static list is stable during iteration and a live one is not. The iterator does not snapshot on its own. Array.from does snapshot, because it copies eagerly, which is the simplest way to keep a loop unaffected by handlers that mutate the tree: convert once, iterate the array, then apply changes.

That pattern also fixes the ordering question. The copy is taken in tree order at one instant, so removals performed during the loop cannot reorder or skip remaining entries, and the code’s behaviour no longer depends on whether the collection happened to be live.

References

  1. DOM Standard: Collections — WHATWG
  2. Selectors API Level 1 — W3C
  3. Document.querySelectorAll() — MDN Web Docs