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.

Bridging childList Mutations into an IntersectionObserver Registry Top row: a new element is appended to the container, MutationObserver reports it in addedNodes, the element node type is checked, and it is registered with observer.observe(). Bottom row: an element is removed from the container, MutationObserver reports it in removedNodes, and it is unregistered with observer.unobserve() to prevent a retained reference. Node added container .appendChild() record.addedNodes [Text, Element] filter ELEMENT_NODE only observer .observe(el) Node removed container .removeChild() record.removedNodes [Element] filter ELEMENT_NODE only observer .unobserve(el) Skipping the bottom row's unobserve() step leaks a strong reference to every removed element inside the IntersectionObserver's registry.

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.

TypeScript
// 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():

TypeScript
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();
    },
  };
}
JavaScript
// 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:

TypeScript
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 a Set for 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 the IntersectionObserver's internal registry.

Common Mistakes to Avoid

  • Not filtering nodeType. Calling observe() on a Text node throws. Always guard with node.nodeType === Node.ELEMENT_NODE.
  • Forgetting pre-existing children. A fresh MutationObserver only reports mutations that happen after observe() is called — nodes already in the container at setup time must be registered separately, once, before you start watching for future changes.
  • Mismatched subtree between the mutation watcher and the actual DOM structure. If new items can appear more than one level below the container, childList alone (without subtree: true) will miss them entirely.
  • Skipping removedNodes handling. This is the single most common source of the bug demonstrated above — every observe() call needs a corresponding unobserve() when that specific element leaves the DOM, not just a blanket disconnect() 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.


↑ Back to MutationObserver DOM Change Tracking