Unobserving Media Elements to Prevent Memory Leaks

IntersectionObserver holds a strong reference to every element you pass to observe(). Removing that element from the DOM does not release it: the observer’s internal observation-target list keeps the node, its decoded bitmap, its attached listeners, and — for <video> — its demuxer and frame queue alive for as long as the observer itself is reachable. On a long-running single-page app that registers a few hundred lazy media elements per route, this is a monotonic leak that only ends in a tab crash. This page is the exact teardown sequence and the heap-snapshot workflow that proves it worked. It assumes the singleton-observer architecture described in Advanced IntersectionObserver Patterns for Media.

Prerequisite checklist

Why a detached image survives

The specification is explicit about the direction of the references, and the direction is the opposite of what most developers assume. An IntersectionObserver has an internal list of observation targets, and that list holds each target strongly. The root, by contrast, is held weakly — an observer never keeps its scroll container alive. And because each observed element carries a registered intersection observers list pointing back at the observer, the observer is itself reachable from any live target. The result is a two-way retain cycle in which a single forgotten target pins the whole graph.

What an un-unobserved target actually retains An object graph. An unmounted route component still holds an IntersectionObserver, which invokes a callback closure and holds its root element only weakly. The observer's internal observation targets slot holds a detached image node with a strong reference, and that node in turn retains a decoded bitmap and a video media resource. Nothing in the chain becomes garbage until unobserve is called. Retention graph after an unmount with no unobserve() SPA route component unmounted IntersectionObserver module-scoped, still live callback closure captures hydrate state holds invokes observation targets internal slot root: scroll container collected normally weak strong detached <img> node removed from document strong decoded bitmap 1600×1200 RGBA ≈ 7.3 MB retains media resource demuxer + frame queue retains Only unobserve() or disconnect() cuts the red edge — dropping the DOM reference on its own does nothing.

Two consequences follow. First, a WeakMap registry keyed on the element — the pattern the parent guide recommends for per-element metadata — cannot save you. The map entry is weak, but the observer’s target list is not, so the element stays reachable and the WeakMap entry stays populated forever. WeakMap protects you from your own registry leaking; it does nothing about the observer’s. Second, the leak is invisible in the Elements panel, because the node is genuinely gone from the document tree. It only shows up in a heap snapshot, filed under Detached.

The complete teardown implementation

One module, four release paths: hydration, DOM removal, route teardown, and media-resource release. Every flag that is not self-evident is annotated.

// mediaObserverPool.js — leak-safe lazy media loader.
// Invariant: an element is in `observer`'s target list ONLY while it is both
// (a) in the document and (b) not yet hydrated. Violating either condition
// on the way out is what produces detached-node growth.

const state = new WeakMap();          // el -> { src, poster, objectUrl, hydrated }
const live  = new Set();              // STRONG set of currently-observed nodes.
                                      // Deliberate: we need to enumerate targets
                                      // during teardown, and IntersectionObserver
                                      // exposes no way to read its own target list.
                                      // Correctness therefore depends on every exit
                                      // path calling release() — see below.

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const el = entry.target;
    // A target can be removed between the browser queueing this record and the
    // task running. Bail early rather than hydrating a node nobody will render.
    if (!el.isConnected) { release(el); continue; }
    if (!entry.isIntersecting || entry.intersectionRatio <= 0) continue;

    hydrate(el, state.get(el));
    release(el);                      // unobserve FIRST, mutate state after:
                                      // hydrate() can throw, and a throw inside a
                                      // forEach callback would otherwise leave the
                                      // target observed forever.
  }
}, { rootMargin: '200px 0px', threshold: [0] });

// A single MutationObserver on the media container catches every element that
// leaves the DOM WITHOUT ever intersecting — the case that leaks in almost every
// hand-rolled implementation. subtree:true is required because frameworks remove
// a wrapper, not the <img> itself; childList alone reports only direct children.
const domWatcher = new MutationObserver((records) => {
  for (const r of records) {
    for (const node of r.removedNodes) {
      if (node.nodeType !== Node.ELEMENT_NODE) continue;
      // The removed node may be an ancestor; sweep its whole former subtree.
      if (live.has(node)) release(node);
      node.querySelectorAll?.('[data-src]').forEach((d) => live.has(d) && release(d));
    }
  }
});

export function observeMedia(el, meta) {
  state.set(el, { hydrated: false, ...meta });
  live.add(el);
  observer.observe(el);
}

export function startWatching(container) {
  domWatcher.observe(container, { childList: true, subtree: true });
}

/** Removes one element from the observer and from our own strong registry. */
function release(el) {
  observer.unobserve(el);             // cuts the observer -> element strong edge
  live.delete(el);                    // cuts OUR strong edge; without this the Set
                                      // leaks exactly as badly as the observer did
  releaseMedia(el);
}

/**
 * Frees resources the GC cannot reclaim just by dropping the JS reference.
 * Applies to elements being torn down mid-flight as well as hydrated ones.
 */
function releaseMedia(el) {
  const s = state.get(el);
  if (s?.objectUrl) {
    URL.revokeObjectURL(s.objectUrl);  // blob bytes are owned by the URL store,
    s.objectUrl = null;                // not by the element — GC never frees them
  }
  if (el.tagName === 'VIDEO' && !el.isConnected) {
    el.pause();
    el.removeAttribute('src');         // must be removeAttribute, not src = ''.
                                       // src = '' resolves against the document URL
                                       // and re-requests the page itself as media.
    el.querySelectorAll('source').forEach((n) => n.remove());
    el.load();                         // forces the resource-selection algorithm to
                                       // re-run against an empty source list, which
                                       // is what actually tears down the decoder
  }
}

/** Call from router beforeEach / useEffect cleanup / disconnectedCallback. */
export function teardown() {
  for (const el of live) releaseMedia(el);
  live.clear();
  observer.disconnect();               // drops ALL remaining targets at once
  domWatcher.disconnect();             // the MutationObserver strongly retains its
                                       // observed root; leaving it live pins the
                                       // entire container subtree after unmount
}

function hydrate(el, s) {
  if (!s || s.hydrated) return;
  s.hydrated = true;
  if (s.srcset) el.srcset = s.srcset;
  el.src = s.src;
}

Warning: observer.disconnect() alone is not a complete teardown. It cuts the observer’s edges, but any Set, Array, Map, event-listener closure, or ResizeObserver you built alongside it holds its own strong references. The live.clear() and domWatcher.disconnect() lines above exist precisely because disconnect() does not reach them.

Tradeoff: keeping a strong Set of live targets buys you an enumerable teardown at the cost of a registry that leaks if release() is ever skipped. The alternative — deriving targets from container.querySelectorAll('[data-src]') at teardown time — cannot see elements the framework already detached, which is the majority of them. Prefer the Set, and treat release() as the single choke point.

Verification

1. Detached-element profile (fastest signal)

Open DevTools → Memory → select Detached elements → navigate through the route twice → click Get detached elements. Chromium lists every detached node still reachable from JavaScript, with its retainer. A leaking observer appears as a row of HTMLImageElement entries whose retainer chain reads IntersectionObserver → observation targets. After the fix, the table should be empty or contain only nodes retained by the framework’s own recycling pool.

2. Count live observers from the console

// DevTools Console only — queryObjects is a console API, not part of the
// language. It forces a GC first, then enumerates every reachable instance
// whose prototype matches the constructor you pass.
queryObjects(IntersectionObserver);   // expect 1 (the singleton), never N-per-route
queryObjects(MutationObserver);       // expect 1 while mounted, 0 after teardown()

If the count grows by one per route change, you are constructing an observer per mount and never disconnecting it — jump to mistake 3 below.

3. Automated heap regression in CI

// leak.test.js — fails the build if the heap does not return to baseline.
// Run with: node leak.test.js   (puppeteer, headless: 'new')
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    args: ['--js-flags=--expose-gc', '--no-sandbox'],
  });
  const page = await browser.newPage();
  const cdp  = await page.createCDPSession();
  await page.goto('https://your-site.com/gallery', { waitUntil: 'load' });

  await cdp.send('Performance.enable');

  const measure = async () => {
    // collectGarbage runs a full, blocking GC — unlike window.gc() it also
    // drains Blink's DOM-node arena, so detached nodes are actually freed.
    await cdp.send('HeapProfiler.collectGarbage');
    const { usedSize } = await cdp.send('Runtime.getHeapUsage');
    // Detached nodes are allocated in Blink's partition, not the JS heap, so
    // usedSize under-reports them. The Performance domain's "Nodes" counter
    // includes every node still reachable — attached or not.
    const { metrics } = await cdp.send('Performance.getMetrics');
    const nodes = metrics.find((m) => m.name === 'Nodes').value;
    return { usedSize, nodes };
  };

  const baseline = await measure();
  for (let i = 0; i < 20; i++) {           // 20 cycles: enough for a per-route
    await page.click('a[href="/detail"]'); // leak to clear measurement noise
    await page.waitForSelector('.detail');
    await page.click('a[href="/gallery"]');
    await page.waitForSelector('.gallery');
  }
  const after = await measure();

  const growthMb   = (after.usedSize - baseline.usedSize) / 1048576;
  const nodeGrowth = after.nodes - baseline.nodes;
  console.log(`heap +${growthMb.toFixed(1)} MB, nodes +${nodeGrowth}`);
  // Thresholds, not zero: JIT code caches grow legitimately, and frameworks
  // keep a small recycled-node pool. Anything proportional to 20 × page size
  // is a leak; single-digit node growth is noise.
  process.exitCode = growthMb > 5 || nodeGrowth > 50 ? 1 : 0;
  await browser.close();
})();

A fixed implementation flatlines; a leaking one climbs linearly with the number of media elements per route. The two profiles are unmistakable once plotted.

Heap growth across twenty route cycles Line chart with route cycles on the horizontal axis and JavaScript heap in megabytes on the vertical axis. The leaking build climbs linearly from 18 megabytes to 138 megabytes over twenty cycles. The fixed build stays between 18 and 22 megabytes for the whole run. JS heap after forced GC, 140 lazy images per route Chromium 126 headless, HeapProfiler.collectGarbage before each sample 0 35 70 105 140 MB 138 MB — no unobserve 20 MB — release() on every exit path 0 4 8 12 16 20 route cycles (gallery → detail → gallery) Linear growth of ~6 MB per cycle equals 140 detached images at roughly 43 KB of retained heap each.

4. Confirm the media element really let go

For <video>, heap size is the wrong instrument — decoder buffers live in the GPU process. Open chrome://media-internals, filter to the player IDs created by your page, and confirm each one reaches the kSuspended/destroyed state after teardown. A player that stays active after its element was removed means removeAttribute('src') plus load() never ran; the same fix is described for autoplaying background clips in how to implement lazy loading for WebM backgrounds.

Common mistakes

1. Trusting WeakMap to release the element

The most widespread misconception in observer-based loaders. A WeakMap keyed on DOM nodes is the right structure for metadata, but it grants no collection guarantee for the key itself. As long as the observer’s target list holds the node, the node is strongly reachable and the map entry stays live.

// WRONG: assumes the WeakMap is the only thing holding `el`.
const meta = new WeakMap();
meta.set(el, { src }); observer.observe(el);
el.remove();                    // node is detached but NOT collectable

// CORRECT: cut the observer edge, then let the WeakMap do its job.
observer.unobserve(el);
el.remove();                    // now the node, its entry, and its bitmap are free

2. Unobserving only inside the isIntersecting branch

Elements the user never scrolled to never intersect, so they never reach the unobserve() call. On a long gallery this is usually 70–90% of the page. Handle removal explicitly — the MutationObserver in the solution above exists for exactly this class of target.

// WRONG: the only unobserve is gated behind visibility.
if (entry.isIntersecting) { hydrate(el); observer.unobserve(el); }

// CORRECT: also release on disconnection from the document.
if (!entry.target.isConnected) { observer.unobserve(entry.target); continue; }
if (entry.isIntersecting) { hydrate(el); observer.unobserve(el); }

3. Constructing an observer inside a component render

Each mount creates a new observer; each observer is retained by its own targets; nothing is ever disconnected. Twenty route visits leave twenty observers, all still firing callbacks against detached nodes and all still wasting intersection computation every frame.

// WRONG: new observer per render, no cleanup returned.
useEffect(() => {
  const io = new IntersectionObserver(cb);
  ref.current && io.observe(ref.current);
});                              // no dependency array => runs every render

// CORRECT: stable dependency list plus an explicit disconnect.
useEffect(() => {
  const el = ref.current;
  if (!el) return;
  observeMedia(el, { src });     // shared singleton from mediaObserverPool.js
  return () => release(el);      // React calls this on unmount AND on re-run
}, [src]);

The same rule applies to framework image components that wrap an observer internally — check whether the component you use disconnects on unmount before layering your own, as covered under Next.js image component optimization.

4. src = '' instead of removeAttribute('src') on <video>

Assigning the empty string does not clear the source: the URL resolves against the document’s base URL, so the media element issues a fresh request for the HTML page itself and holds an open connection plus a failed-decode buffer. Always removeAttribute('src'), drop any <source> children, then call load() to re-run resource selection against an empty list.

5. Leaving the MutationObserver connected after unmount

MutationObserver retains its observed root strongly, and its root is usually the container holding every media element on the route. Forgetting domWatcher.disconnect() therefore pins the entire subtree — a strictly larger leak than the one you set out to fix. The teardown map below is worth keeping next to the code: each call releases a different class of resource, and no single call releases all of them.

What each teardown call actually releases A matrix of five teardown calls against four resource classes. Unobserve and disconnect release the detached DOM node and its decoded bitmap but not media buffers or blob bytes. Removing the element alone releases nothing. Removing the video source and calling load releases the media decoder buffers. Revoking the object URL releases the blob bytes. No single call covers all four columns. Teardown call detached DOM node decoded bitmap media decoder buffers blob bytes observer.unobserve(el) observer.disconnect() el.remove() with no unobserve removeAttribute('src') + load() URL.revokeObjectURL(url) A dash means not applicable. Complete teardown requires rows one or two PLUS rows four and five.

Treat that matrix as the acceptance criterion for any lazy-media abstraction you adopt: if the library’s teardown story stops at disconnect(), you still own the media and blob columns. Once the observer side is provably clean, the remaining question is whether you needed the observer at all — the IntersectionObserver vs native lazy loading benchmarks measure exactly that, and loading="lazy" has no teardown surface to get wrong.