A pool either bounds observer memory or quietly becomes a new place for it to accumulate. A heap snapshot answers which, in about two minutes, and the technique generalises to any module-scope registry.

Problem / Scenario Context

An application is reported to "get slower the longer you use it". The pool was added specifically to prevent that, so it is either not working or not the cause. Both possibilities are testable, and guessing between them wastes days.

The measurement is a paired snapshot: take a baseline, exercise the application through a repeatable cycle, force collection, take a second snapshot, and compare. What distinguishes a healthy pool from a leaking one is not the absolute numbers but whether they return after the cycle.

Mechanics Explanation

Three object families are worth looking at, and they fail in different ways.

Observer instances. In a healthy pool the count equals the number of distinct configurations currently in use — typically a handful. A count that tracks the number of views visited means scopes are not being released, as covered in disconnecting pooled observers on route change.

Detached elements. These are DOM nodes removed from the document but still reachable. A pool that stores handlers in a WeakMap cannot retain them; a pool that used a Map will, and the Retainers pane will name it.

Handler closures. Each registration's handler captures its component scope. If handlers outlive their components, the closure keeps the component's state alive — which is often far larger than the element itself.

The key discipline is that a snapshot alone proves nothing. Memory legitimately grows during use; what matters is whether it comes back down after a full cycle and a forced collection.

Baseline, Cycle, Collect, CompareA four-step measurement loop. A baseline snapshot is taken on the starting view. The application is then cycled through several navigations and back to the same view. Garbage collection is forced. A second snapshot is compared against the baseline. A healthy pool returns to its baseline counts; a leaking one shows instance and detached-node counts proportional to the number of cycles.1. baselineon view A2. cycleA → B → C → A, ten times3. force GCthe bin icon4. compareback on view AhealthyIntersectionObserver: 4 → 4(detached) elements: 0counts return to baselineleakingIntersectionObserver: 4 → 34(detached) elements: 1,240counts scale with cycle countThe diagnosis is whether the numbers come BACK, not how large they got

Comparison Table: What Each Filter Tells You

Filter in the Memory panel Healthy Leaking Points at
IntersectionObserver a handful, stable grows per navigation scope release missing
(detached) 0 after GC hundreds a Map where a WeakMap belonged
Map retaining elements none present the pool's own registry
WeakMap present, not a retainer present, not a retainer nothing — this is expected
Closure objects stable grows handlers outliving components
Detached HTMLDivElement retained by a closure 0 many a handler capturing its element

The fourth row is worth stating explicitly because it causes false alarms. A WeakMap appears in the class list and looks suspicious. It is not a retainer of its keys, and its presence is exactly what you want to see.

Minimal Reproducible Example

TypeScript
// A pool that leaks by construction: strong keys
const handlers = new Map<Element, Handler>();      // ← Map, not WeakMap
// Every element ever registered stays reachable from module scope, forever,
// even after unobserve() — because unobserve does not touch this Map.

Production-Safe Solution

The measurement is the deliverable here, so it is worth scripting the exercise rather than clicking through it:

TypeScript
// Paste into the console; run between snapshots for a repeatable cycle
async function cycle(paths: string[], times = 10): Promise<void> {
  for (let i = 0; i < times; i++) {
    for (const p of paths) {
      history.pushState({}, '', p);
      dispatchEvent(new PopStateEvent('popstate'));
      await new Promise((r) => setTimeout(r, 250));   // let the view mount and settle
    }
  }
}
await cycle(['/reports', '/settings', '/reports']);

And a runtime assertion is cheaper than a snapshot for catching regressions:

TypeScript
export function poolReport(): { instances: number; keys: string[] } {
  return { instances: pool.size, keys: [...pool.keys()] };
}

if (import.meta.env.DEV) {
  (window as unknown as Record<string, unknown>).__poolReport = poolReport;
}

With that exposed, the whole diagnosis becomes a one-liner in the console before and after the cycle — and, more usefully, an assertion in an end-to-end test:

TypeScript
test('pool does not grow across navigation', async ({ page }) => {
  await page.goto('/reports');
  const before = await page.evaluate(() => (window as any).__poolReport().instances);
  for (let i = 0; i < 5; i++) {
    await page.getByRole('link', { name: 'Settings' }).click();
    await page.getByRole('link', { name: 'Reports' }).click();
  }
  const after = await page.evaluate(() => (window as any).__poolReport().instances);
  expect(after).toBe(before);
});

That test is the real fix. A heap snapshot diagnoses the problem once; the assertion stops it coming back.

What the Retainers Pane NamesTwo retainer chains for a detached element. In the leaking case the chain runs from a GC root through a module variable to a Map and then to the element, so the Map is named as the retainer. In the healthy case no chain exists: the WeakMap appears in the class list but never in the retainers pane, which is the distinction that settles the diagnosis.leaking — the chain existsGC rootmodule: poolMap handlers(detached) HTMLDivElementhealthy — no chain to drawWeakMap handlersappears in the class list, never in Retainers — the element is simply gone

Snapshots are large; take fewer, better ones

A heap snapshot of a real application is tens of megabytes and takes several seconds to capture and to diff, which tempts people into taking one snapshot and reasoning about absolute numbers. Two snapshots around a repeatable cycle answer the question far better than five taken at arbitrary moments, because the comparison view does the arithmetic and highlights only what survived the cycle. Capture the baseline on a settled page, keep the cycle identical between runs, and resist adding steps to it — a cycle that varies between the two captures produces differences that have nothing to do with the leak.

Verification Steps

  • Take the paired snapshot with a forced collection between the cycle and the second capture, or the comparison is meaningless.
  • Filter for (detached) and read the Retainers column, not the count alone — the retainer is the actionable part.
  • Check the pool key list, not just the size: a pool of eight where the keys are near-duplicates points at key normalisation rather than at teardown.
  • Add the end-to-end assertion so the finding becomes a regression test rather than a memory of an afternoon.

Snapshot or Assertion?Two diagnostic tools compared. A heap snapshot is precise and slow, is used once to name a retainer, and cannot run in continuous integration. A pool size assertion is coarse and instant, runs on every build, and prevents the regression rather than diagnosing it.heap snapshotprecise: it names the retainerslow, manual, cannot run in CIuse it once, to diagnosepool size assertioncoarse: a number, not a causeinstant, automatic, every buildkeep it forever, to preventThe snapshot answers why it happened; the assertion makes sure it does not happen twice.

Common Mistakes to Avoid

  • Comparing snapshots without forcing collection. Uncollected garbage looks exactly like a leak.
  • Treating the presence of a WeakMap as suspicious. It is expected, and it is never a retainer.
  • Measuring one navigation. Growth per cycle is the signal; a single cycle cannot show a trend.
  • Fixing the pool when the leak is elsewhere. A handler that captures its component is a component bug the pool merely makes visible.

FAQ

What exactly should I filter for in the Memory panel?

Start with the observer constructor name to count instances, then filter for detached to find retained DOM. The count answers whether teardown is working; the Retainers column on a detached node answers what is holding it, which is the part you can act on.

A WeakMap shows up in the snapshot — is that the leak?

No. WeakMaps appear in the class list like any other object, but they never appear as a retainer of their keys, which is precisely the property that makes them right for an observer registry. Seeing one is expected; seeing a Map holding elements is the finding.

How many cycles should the exercise run?

Enough that accumulation is unmistakable — ten navigations through the same loop is usually plenty. A single cycle cannot distinguish a leak from ordinary allocation, because the interesting quantity is the slope, not the value.

Is a growing pool always a bug?

Not necessarily. An application that genuinely uses many distinct observer configurations will have a larger pool, and that is working as intended. The bug is growth that tracks the number of navigations rather than the number of configurations, which is what the key list tells you at a glance.


↑ Back to Shared Observer Pooling