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.
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
// 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:
// 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:
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:
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.
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.
Common Mistakes to Avoid
- Comparing snapshots without forcing collection. Uncollected garbage looks exactly like a leak.
- Treating the presence of a
WeakMapas 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.
Related
- Disconnecting Pooled Observers on Route Change — the usual cause when instance count tracks navigations
- Keying an Observer Pool by Threshold and rootMargin — the usual cause when the key list is full of near-duplicates
- WeakMap vs Map for Observer Target Tracking — the choice this snapshot makes visible
↑ Back to Shared Observer Pooling