A pool is module state, and module state outlives routes. That is the whole point on a multi-page site and a slow leak in a client-routed application.

Problem / Scenario Context

An admin console runs for hours. Its observer pool is a module-level Map, and every view registers a few configurations: a lazy-image margin here, an impression threshold there, a scroll-spy band on the documentation view. Reference counting is correct, so an instance is dropped when its last element unregisters.

Except that it usually is not the last. Route changes in this application unmount some components and leave others mounted — a persistent shell, a notification tray, a sidebar. Those keep their registrations, so the instances they hold never fall to zero, and every configuration that has ever been used accumulates. After an hour of navigation the pool holds forty instances for a view that needs three.

Nothing is broken. Nothing errors. The heap simply climbs, and a profile taken during an incident shows forty observers whose targets are almost all detached.

Mechanics Explanation

Two distinct lifetimes are being conflated.

Component lifetime. A registration belongs to a component and is released when it unmounts. Reference counting handles this correctly and needs no help.

Route lifetime. A registration belongs to a view, and everything about that view should disappear when the reader navigates away — including registrations from components that failed to unregister because they were removed without their cleanup running, which happens with error boundaries, aborted transitions and some framework-specific teardown paths.

Layering the second over the first means the pool needs a scope concept: registrations tagged with the route they were made under, and a bulk release when that route ends. That is strictly more than disposeAll(), because a global dispose would also tear down the persistent shell's registrations, which are still live and would silently stop working.

Pool Growth Across Six NavigationsA step chart of pool instance count across six route changes. Without scoped teardown the count rises at each navigation and never falls, reaching fifteen. With scoped teardown the count rises when a view mounts and falls again when it unmounts, oscillating between three and six. A note marks that the persistent shell's registrations survive in both cases.Pooled instance count over six client-side navigationsno scoped teardown — 15 instances and climbingscoped teardown — oscillates between 3 and 6navigations →instances

Comparison Table: Teardown Strategies

Strategy Removes stale instances Keeps the shell working Effort
Nothing no yes none
Reference counting only only when components unregister cleanly yes already in the pool
disposeAll() on navigation yes no — breaks persistent components trivial
Scoped registration + scope release yes yes one extra parameter
Idle sweep of zero-count slots partially yes a timer

The third row is the trap. It is the obvious implementation, it removes the growth, and it silently disables the notification tray's impression tracking on every navigation — a bug that surfaces weeks later as "analytics undercounts on some sessions".

Minimal Reproducible Example

TypeScript
// Removes the growth and the shell's observers with it
router.afterEach(() => disposeAll());
// The persistent header's scroll-spy stops working after the first navigation.

Production-Safe Solution

Tag each registration with a scope, and release scopes rather than everything:

TypeScript
type Scope = string;
const GLOBAL: Scope = 'app';

interface Pooled { observer: IntersectionObserver; handlers: WeakMap<Element, Handler>; count: number; }
const pool = new Map<string, Pooled>();
const byScope = new Map<Scope, Set<() => void>>();

export function observe(
  el: Element,
  handler: Handler,
  opts: IntersectionObserverInit = {},
  scope: Scope = GLOBAL,
): () => void {
  const key = keyFor(opts);
  let slot = pool.get(key);
  if (!slot) {
    const handlers = new WeakMap<Element, Handler>();
    const observer = new IntersectionObserver((entries) => {
      for (const e of entries) {
        try { handlers.get(e.target)?.(e); }
        catch (err) { console.error('observer handler threw', err); }   // one bad handler must not starve the batch
      }
    }, opts);
    slot = { observer, handlers, count: 0 };
    pool.set(key, slot);
  }

  slot.handlers.set(el, handler);
  slot.count += 1;
  slot.observer.observe(el);

  let released = false;
  const release = () => {
    if (released) return;
    released = true;
    slot!.observer.unobserve(el);
    slot!.handlers.delete(el);
    byScope.get(scope)?.delete(release);
    if (--slot!.count === 0) { slot!.observer.disconnect(); pool.delete(key); }
  };

  if (!byScope.has(scope)) byScope.set(scope, new Set());
  byScope.get(scope)!.add(release);
  return release;
}

/** Release everything registered under one scope. Safe to call more than once. */
export function releaseScope(scope: Scope): void {
  const set = byScope.get(scope);
  if (!set) return;
  for (const release of [...set]) release();     // copy: release() mutates the set
  byScope.delete(scope);
}

export const poolSize = () => pool.size;

Wiring it into a router is then two lines, and the scope name is the thing that keeps the shell safe:

TypeScript
// The route's own view registers under the route scope
router.afterEach((to, from) => {
  if (from?.fullPath) releaseScope(`route:${from.fullPath}`);
});

// Persistent shell components register with no scope argument, so they use 'app'
observe(headerSentinel, onPin);                       // survives navigation
observe(card, onVisible, OPTS, `route:${route.fullPath}`);   // released with the route

Two refinements are worth adding once this is in place.

A development assertion. A pool that should hold three or four instances for a given view is easy to check, and the check catches a regression the day it lands rather than during an incident:

TypeScript
if (import.meta.env.DEV) {
  router.afterEach(() => {
    queueMicrotask(() => {
      if (poolSize() > 8) console.warn(`observer pool has ${poolSize()} instances — scope leak?`);
    });
  });
}

An idle sweep as a backstop, for registrations that escape their scope entirely — a component removed by a third-party script, for example:

TypeScript
setInterval(() => {
  for (const [key, slot] of pool) {
    if (slot.count === 0) { slot.observer.disconnect(); pool.delete(key); }
  }
}, 60_000);

Two Scopes, One PoolA pool holding four instances, split by scope. Two are registered under the application scope by the persistent shell and survive navigation. Two are registered under a route scope by the current view and are released when that route ends. A global dispose would incorrectly take all four; releasing the route scope takes only the correct two.scope: 'app'header sentinel — sticky pinnotification tray — impression trackingsurvives every navigationscope: 'route:/reports'report cards — lazy chartstable rows — infinite scroll sentinelreleased when the route endsdisposeAll() on navigation would take the left-hand box too — and nothing would report the shell's observers as missing.

Verification Steps

  • Log poolSize() after each navigation during a development session; it should return to the same baseline rather than climbing.
  • Navigate away and back ten times and take a heap snapshot; the observer instance count should match the current view's needs, not the sum of every view visited.
  • Confirm the shell still works after navigating — pin the header, check the tray records an impression.
  • Force an error boundary mid-view and confirm the scope release still cleans up, since component cleanups may not have run.

A Development Assertion Worth KeepingA pool size check running after every navigation in development. With scopes released correctly the instance count stays flat across navigations; without, it climbs and the warning fires, turning a slow leak into an immediate signal during ordinary development.Catch the regression on the branch, not during an incidentrouter.afterEach(() => queueMicrotask(() => { if (poolSize() > 8) console.warn(pool has ${poolSize()} instances); }));scopes released — flat at 4the warning fires herenavigations →

Common Mistakes to Avoid

  • Calling disposeAll() on navigation. It fixes the growth and silently disables everything persistent.
  • Scoping by component instead of route. That is what reference counting already does; the scope exists for the case where cleanup did not run.
  • Releasing the new route's scope instead of the old one. An easy off-by-one in afterEach, and the symptom is that the view you just navigated to has no observers.
  • Iterating the scope set while releasing. release() mutates it; copy before looping.

FAQ

Why is reference counting not enough on its own?

Because it only fires when a component's cleanup actually runs. Error boundaries, aborted transitions and elements removed by third-party code all skip that path, leaving the count above zero forever. A scoped release is the backstop for registrations that never got the chance to release themselves.

Why not just call disposeAll on every navigation?

Because a single-page application usually has components that legitimately outlive routes — a persistent header, a notification tray, a sidebar. A global dispose tears those down too, and nothing reports the loss, so the symptom appears much later as missing analytics or a header that stopped pinning.

What should the scope key be?

Something that changes exactly when the set of registrations should be discarded — usually the route path. Anything finer, such as a component identifier, duplicates what reference counting already does; anything coarser fails to release on navigation at all.

Do I still need an idle sweep if scopes are wired correctly?

It is a cheap backstop rather than a requirement. Registrations made outside any known scope, or by code you do not control, can still escape both mechanisms, and a periodic pass that drops slots whose count has reached zero costs almost nothing on a page that is otherwise correct.


↑ Back to Shared Observer Pooling