When a container's children are rendered dynamically — infinite scroll batches, virtualized rows, a chat window appending new messages — any IntersectionObserver or ResizeObserver you've attached only ever sees the elements you explicitly called observe() on at setup time. New elements that appear later are invisible to those observers until something notices they exist. That "something" is almost always a MutationObserver watching the container's childList.
Problem / Scenario Context
Consider a live comment feed: a WebSocket handler appends a new <li> to a <ul> every time a comment arrives, and each comment should lazily load its avatar image only once it scrolls into view. An IntersectionObserver handles the lazy-loading logic beautifully — but it has to be told about each <li> individually. If the append happens outside of any code path that also calls observer.observe(newLi), the new comment's avatar never lazy-loads, because the observer has no way to discover DOM changes on its own.
The fix is to pair the two observers: a MutationObserver watches the container for childList mutations, and its callback bridges newly added elements into the IntersectionObserver's registry — and, just as importantly, removes departing elements from that registry so the instance doesn't accumulate stale targets. This pattern generalizes past lazy loading: any code that must react uniformly to "an element like this exists in the DOM" needs a childList watcher as its entry point, because none of the other observer APIs create their own discovery mechanism.
Mechanics Explanation
MutationRecord.addedNodes and MutationRecord.removedNodes are both live NodeList snapshots captured at the moment of a single insertion or removal operation. They are not filtered by node type — appending an element next to a stray whitespace text node (common with templated HTML) produces a record whose addedNodes includes both the Text node and the Element. Your callback must check node.nodeType === Node.ELEMENT_NODE before doing anything element-specific, or you'll hit runtime errors calling DOM methods on a Text node.
The diagram below shows the full round trip: a node enters the container, gets filtered and registered with the child observer; a node leaves, gets filtered and unregistered.
Notice the symmetry: whatever registration step happens on add must have a matching teardown step on remove. This is the same discipline covered generally in Observer Lifecycle & Memory Management — a MutationObserver-driven bridge is just a case where the "leak" is per-element rather than per-component.
subtree matters here too: if new comments render as <li><div class="comment">…</div></li> and your container only observes its own direct children with childList: true (no subtree), you will still see the <li> in addedNodes — direct children are always reported. But if a templating library instead re-parents nodes into an intermediate wrapper you don't control, set subtree: true and check record.target to make sure the mutation happened at the level you expect. The full options schema and MutationRecord field reference lives in the MutationObserver DOM Change Tracking guide.
Comparison Table: Ways to Discover Dynamically Added Elements
| Approach | Detects additions | Detects removals | Cost |
|---|---|---|---|
Re-run querySelectorAll on a timer |
Yes, with polling delay | Yes, with polling delay | Wasteful — full subtree re-scan every tick |
Call observe() manually at every insertion call site |
Yes, if every call site remembers to | No | Fragile — breaks the moment one code path forgets |
MutationObserver with childList (+ subtree if nested) |
Yes, immediately (batched) | Yes, immediately (batched) | Low — one observer, no polling, no re-scans |
The manual-call-site approach looks simplest at first but scales poorly: every place in the codebase that inserts a node into the watched container must remember to also call observe(). A MutationObserver bridge centralizes that responsibility in one place, so new insertion code paths get lazy-loading and cleanup for free.
Minimal Reproducible Example
This naive version demonstrates the bug: it registers new elements correctly but never unregisters removed ones.
// BUG: never calls intersectionObserver.unobserve() on removal
function watchContainerNaive(
container: Element,
intersectionObserver: IntersectionObserver
): MutationObserver {
const mutationObserver = new MutationObserver((records: MutationRecord[]) => {
for (const record of records) {
record.addedNodes.forEach((node: Node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
intersectionObserver.observe(node as Element);
}
});
// removedNodes is read but never acted on — leak
}
});
mutationObserver.observe(container, { childList: true });
return mutationObserver;
}
Run this against a container that adds and removes a few hundred rows over a session, then inspect the IntersectionObserver's internal target count in a heap snapshot — it grows monotonically instead of tracking the live DOM.
Production-Safe Solution
The corrected version filters both NodeLists to Element nodes and mirrors every observe() call with a matching unobserve():
interface NodeWatcherHandle {
disconnect(): void;
}
/**
* Bridges childList mutations on `container` into an IntersectionObserver:
* newly added elements are auto-observed, removed elements are auto-unobserved.
*/
function watchContainerForVisibility(
container: Element,
intersectionObserver: IntersectionObserver
): NodeWatcherHandle | null {
if (typeof MutationObserver === 'undefined') return null;
const mutationObserver = new MutationObserver((records: MutationRecord[]) => {
for (const record of records) {
if (record.type !== 'childList') continue;
record.addedNodes.forEach((node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return; // skip Text/Comment nodes
intersectionObserver.observe(node as Element);
});
record.removedNodes.forEach((node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
intersectionObserver.unobserve(node as Element); // prevent retained reference
});
}
});
mutationObserver.observe(container, { childList: true, subtree: false });
return {
disconnect(): void {
mutationObserver.disconnect();
intersectionObserver.disconnect();
},
};
}
// Plain JS equivalent
function watchContainerForVisibility(container, intersectionObserver) {
if (typeof MutationObserver === 'undefined') return null;
const mutationObserver = new MutationObserver((records) => {
for (const record of records) {
if (record.type !== 'childList') continue;
record.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
intersectionObserver.observe(node);
});
record.removedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
intersectionObserver.unobserve(node);
});
}
});
mutationObserver.observe(container, { childList: true, subtree: false });
return {
disconnect() {
mutationObserver.disconnect();
intersectionObserver.disconnect();
},
};
}
React integration — wire the combined teardown into useEffect's cleanup:
import { useEffect, useRef } from 'react';
function useAutoObserveChildren(
containerRef: React.RefObject<HTMLElement>,
onVisible: (el: Element) => void
): void {
useEffect(() => {
if (!containerRef.current) return;
const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => entry.isIntersecting && onVisible(entry.target));
}, { threshold: 0.1 });
// Observe children already present at mount time
Array.from(containerRef.current.children).forEach((el) => intersectionObserver.observe(el));
const handle = watchContainerForVisibility(containerRef.current, intersectionObserver);
return () => handle?.disconnect();
}, [onVisible]);
}
Note the line observing pre-existing children before attaching the MutationObserver — the mutation watcher only reports future changes, so anything already in the DOM at setup time needs to be registered manually once, up front.
Verification Steps
- Baseline count: Log
intersectionObserver's registered target count (or track it yourself in aSetfor debugging) before inserting any rows. - Stress the container: Programmatically append and remove 100–200 rows in a loop, simulating a busy live feed.
- Confirm symmetry: After the loop, the tracked target count should return to its starting value (plus whatever rows remain genuinely present in the DOM) — not grow unbounded.
- Heap snapshot: Take a DevTools heap snapshot, filter for
(detached), and confirm no stray<li>elements are retained by theIntersectionObserver's internal registry.
Common Mistakes to Avoid
- Not filtering
nodeType. Callingobserve()on aTextnode throws. Always guard withnode.nodeType === Node.ELEMENT_NODE. - Forgetting pre-existing children. A fresh
MutationObserveronly reports mutations that happen afterobserve()is called — nodes already in the container at setup time must be registered separately, once, before you start watching for future changes. - Mismatched
subtreebetween the mutation watcher and the actual DOM structure. If new items can appear more than one level below the container,childListalone (withoutsubtree: true) will miss them entirely. - Skipping
removedNodeshandling. This is the single most common source of the bug demonstrated above — everyobserve()call needs a correspondingunobserve()when that specific element leaves the DOM, not just a blanketdisconnect()at the very end of the container's life.
FAQ
Do addedNodes and removedNodes only contain elements?
No. Both NodeLists can contain any node type that was inserted or removed as a direct child, including Text nodes (whitespace between tags counts) and Comment nodes. Always check nodeType === Node.ELEMENT_NODE before treating an entry as a DOM element you can call querySelector or observe() on.
Do I need subtree: true to catch nodes added several levels deep?
Yes. Without subtree: true, childList only reports direct children of the observed target. If new nodes can appear nested inside an intermediate wrapper rather than as a direct child, you must set subtree: true so mutations anywhere below the target are reported, then filter to the specific elements you care about inside the callback.
What happens if I forget to unobserve a removed node's child IntersectionObserver?
The IntersectionObserver keeps a strong internal reference to the removed element, which prevents that detached subtree from being garbage collected even though it no longer renders. In a page that adds and removes many nodes over time, this accumulates into a classic detached-node memory leak. Always call unobserve() (or disconnect() the whole watcher) for every element reported in a removedNodes list.
Related
- MutationObserver DOM Change Tracking — parent guide covering options, MutationRecord fields, and batching
- IntersectionObserver API Deep Dive — the observer most commonly bridged with childList watching
- Observer Lifecycle & Memory Management — the general disconnect() and WeakMap discipline this pattern relies on
- Preventing Memory Leaks in Long-Running Observers — detached-node retention chains in more depth
↑ Back to MutationObserver DOM Change Tracking