In the browser DOM, what is the difference between the live HTMLCollection returned by document.getElementsByClassName() and the static NodeList returned by document.querySelectorAll(), and how does that difference break a loop that removes elements?
answer
- one keeps watching, one is a photo
- getElementsBy* versus querySelectorAll
- length re-read on every iteration
- indexes shift under a forward loop
- iterate backwards, or snapshot first
basics
~20 sgetElementsByClassName returns a live HTMLCollection that keeps re-reflecting the document, while querySelectorAll returns a static NodeList captured once. Looping forward over the live collection while removing elements shifts the indexes and skips every other match.
solid answer
~40 sAn `HTMLCollection` — what `getElementsByClassName`, `getElementsByTagName`, `document.forms` and `element.children` hand back — is **live**: it is a standing query, so every read of `.length` or `collection[i]` reflects the tree as it is right now. `querySelectorAll` returns a **static** `NodeList`: the matching set is computed once and never changes afterwards, even if you add or remove matching elements. That matters the moment you mutate while iterating. In `for (let i = 0; i < live.length; i++) live[i].remove()`, removing index 0 slides every remaining element down one slot while `i` moves up, so you process only half of them; append matching elements inside the loop instead and it never terminates. Fixes: iterate the live collection backwards, drain it with `while (col.length) col[0].remove()`, or take a snapshot with `Array.from(col)` or `querySelectorAll`.
code
javascript · 12 linesconst list = document.createElement('ul');
list.innerHTML = '<li class="row">a</li><li class="row">b</li>' +
'<li class="row">c</li><li class="row">d</li>';
document.body.append(list);
const live = list.getElementsByClassName('row'); // live HTMLCollection
for (let i = 0; i < live.length; i++) live[i].remove();
console.log(live.length); // 2 — every other item survived
const snapshot = list.querySelectorAll('.row'); // static NodeList
for (let i = 0; i < snapshot.length; i++) snapshot[i].remove();
console.log(live.length); // 0 — the snapshot still had them allgo deeper
Be able to name which calls give a live list (getElementsByClassName, getElementsByTagName, children) and which give a snapshot (querySelectorAll), and say plainly that removing elements inside a forward loop over a live list skips some.
Explain the mechanism: the live collection re-resolves length and each index against the current tree, so removals slide elements down while the counter moves up. Show at least two correct rewrites, such as looping backwards or snapshotting first.
Demonstrate the operational instinct — spot the non-terminating insert variant in review, notice a hot loop that re-reads getElementsByTagName(...).length on every iteration, and set a team rule about snapshotting before any iterate-and-mutate pass.
Own the guidance: decide when live collections are worth keeping for their self-correcting count versus banning them in shared code because they make index-based reasoning unsafe, and make the choice visible in lint rules or helper APIs rather than tribal knowledge.
## Two different list interfaces The DOM returns element lists through two interfaces, and the difference is not cosmetic. - `HTMLCollection` — returned by `document.getElementsByClassName()`, `document.getElementsByTagName()`, `element.children`, and the legacy named collections `document.forms`, `document.images`, `document.links`. An `HTMLCollection` is **always live**. - `NodeList` — returned by `document.querySelectorAll()` (**static**) and by `node.childNodes` (**live**). So "NodeList" alone does not tell you which behaviour you get; the producing API does. A static list also holds `getElementsByName` style legacy oddities aside — the practical rule to memorise is: **everything named `getElementsBy*` plus `children` is live; `querySelectorAll` is a snapshot.** ## What "live" really means A live collection is not a snapshot that gets patched. It is closer to a standing query object: reading `.length` or indexing into it asks the current document "which elements match right now?". Engines cache the answer internally and invalidate that cache on any relevant mutation, but the observable semantics are as if the tree were re-queried on every access. Static means the opposite: `querySelectorAll` walks the tree once, builds the list, and returns it. Later insertions that would have matched are absent; later removals leave their elements in the list. One nuance people get wrong: *static does not mean copies*. Both list kinds contain the same live element objects. A static `NodeList` that still holds an element you removed from the document gives you a real, detached element — you can inspect it and re-insert it. ## The loop that eats half your elements ```js const rows = container.getElementsByClassName('row'); // live for (let i = 0; i < rows.length; i++) { rows[i].remove(); } ``` With four rows: `i = 0` removes the first, so the collection immediately becomes three elements long and the old second element is now at index 0. `i = 1` therefore points at what was the third element. After two removals `rows.length` is 2 and the loop condition fails. Two of the four survive. Nothing threw; the code just quietly did half the job. The mirror-image bug is worse. If the body of the loop *creates* elements that match the selector, `rows.length` grows at least as fast as `i`, and the loop never ends — the tab hangs. The same trap appears without removal. `for (let i = 0; i < list.getElementsByTagName('li').length; i++)` re-resolves the collection and its length on every single iteration, which is real work in a large tree. ## Four ways to write it correctly ```js // 1. iterate backwards — indexes you have not visited never move for (let i = rows.length - 1; i >= 0; i--) rows[i].remove(); // 2. drain from the front while the collection is non-empty while (rows.length) rows[0].remove(); // 3. snapshot the live collection before touching the tree for (const row of Array.from(rows)) row.remove(); // 4. ask for a static list in the first place for (const row of container.querySelectorAll('.row')) row.remove(); ``` Option 4 is the usual answer in modern code, and it is why the live-collection trap now surfaces mainly in older codebases — but `element.children` is live too, and that one is still everywhere. ## When liveness is the feature Live collections predate `querySelectorAll` and were designed for exactly the case where you want a running count rather than a snapshot: `document.forms.length`, or `container.children.length` as a cheap emptiness check after arbitrary mutations. If you hold a live collection across a render pass, it stays correct for free where a static list would have gone stale. The cost is that you can never assume an index means the same element two statements apart. ## How to talk about it in an interview State the rule (`getElementsBy*` and `children` are live, `querySelectorAll` is static), then show that you know *why* the removal loop misbehaves — index shifting under a re-evaluated `length`, not "the browser deletes items from your array". Mentioning the non-terminating insert variant, and that a static list still contains real element references rather than copies, is what separates a memorised fact from an understood one.
- Which of the two lists would you hold on to across several DOM updates, and why?The live `HTMLCollection`, if what you want is a current count or the current first match — it stays correct without re-querying. A static `NodeList` held across updates silently goes stale: it keeps elements you removed and misses elements you added, so it should be treated as valid only for the statement that produced it.
- Does a static NodeList contain copies of the elements?No. Both list kinds contain references to the same element objects; only the membership of the list is frozen. That is why a static list can still hand you an element after you removed it from the document — you get the real detached element, and you can re-insert it or read its state from it.
- What happens if the body of a forward loop over a live collection creates elements that also match?The loop never terminates. Each iteration grows the collection at least as fast as the index advances, so `i < collection.length` stays true forever and the tab freezes. It is the same index-versus-live-length bug as the removal case, just failing loudly instead of quietly.
saying these in an interview costs you the question
- Says querySelectorAll returns a live list that auto-updates
- Thinks a static NodeList holds copies of the elements
- Claims element.children is static because children is not a getElementsBy call
- Blames the removal skip on remove() rather than index shifting
- Assumes any collection can be indexed safely after mutating the tree