This page covers the non-network half of the teardown contract in State Reset & Cleanup Protocols; the request side lives in Aborting In-Flight Fetches with AbortController on Boundary Reset.

The exact problem #

A live-orders panel polls every five seconds, listens for visibilitychange, observes its own size, and holds a WebSocket. It crashes on a malformed message. The user retries. The subtree remounts and starts a second poller, a second listener, a second observer, a second socket.

After six retries the tab is issuing six requests every five seconds, six handlers respond to every visibility change, and the browser is holding six detached component trees alive because the old timers still reference them. The page gets slower with every recovery β€” the opposite of what recovery is for.

Zero-to-working disposer bag #

// disposers.ts
type Teardown = () => void;

export class DisposerBag {
  #teardowns: Teardown[] = [];
  #disposed = false;

  add(teardown: Teardown): void {
    if (this.#disposed) { teardown(); return; }   // registered after disposal: run now
    this.#teardowns.push(teardown);
  }

  interval(fn: () => void, ms: number): void {
    const id = setInterval(fn, ms);
    this.add(() => clearInterval(id));
  }

  timeout(fn: () => void, ms: number): void {
    const id = setTimeout(fn, ms);
    this.add(() => clearTimeout(id));
  }

  listen<K extends keyof WindowEventMap>(
    target: EventTarget, type: K | string, handler: EventListener, opts?: AddEventListenerOptions,
  ): void {
    target.addEventListener(type as string, handler, opts);
    this.add(() => target.removeEventListener(type as string, handler, opts));
  }

  observe(observer: { disconnect(): void }): void {
    this.add(() => observer.disconnect());
  }

  socket(ws: WebSocket): void {
    this.add(() => {
      // Detach handlers first: close() fires onclose, which may re-enter app code.
      ws.onmessage = null;
      ws.onerror = null;
      ws.onclose = null;
      if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(1000, 'unmount');
    });
  }

  dispose(): void {
    if (this.#disposed) return;                   // idempotent
    this.#disposed = true;
    // Reverse order: later resources may depend on earlier ones.
    for (const teardown of this.#teardowns.reverse()) {
      try {
        teardown();
      } catch (err) {
        // One broken disposer must not strand the rest.
        reportCrash({ scope: 'cleanup', message: err instanceof Error ? err.message : String(err) });
      }
    }
    this.#teardowns = [];
  }
}

export function useDisposers(): DisposerBag {
  const ref = useRef<DisposerBag>();
  ref.current ??= new DisposerBag();
  useEffect(() => () => { ref.current?.dispose(); ref.current = undefined; }, []);
  return ref.current;
}
// LiveOrders.tsx β€” every resource goes through the bag
export function LiveOrders() {
  const bag = useDisposers();
  const [orders, setOrders] = useState<Order[]>([]);

  useEffect(() => {
    bag.interval(() => void refresh(setOrders), 5000);

    bag.listen(document, 'visibilitychange', () => {
      if (document.visibilityState === 'visible') void refresh(setOrders);
    });

    const ro = new ResizeObserver(() => relayout());
    ro.observe(document.body);
    bag.observe(ro);

    const ws = new WebSocket('/ws/orders');
    ws.onmessage = (e) => setOrders(parseOrders(e.data));   // may throw β†’ boundary
    bag.socket(ws);
  }, [bag]);

  return <OrdersTable orders={orders} />;
}
Resource accumulation across retries, with and without cleanup Two bar groups. The left group, labelled no cleanup, shows four bars growing taller across mount one to mount four, reaching four intervals, four listeners, four observers and four sockets. The right group, labelled disposer bag, shows four equal short bars, each returning to one of each resource. no cleanup disposer bag mount 1 retry 1 retry 2 retry 3 mount 1 retry 1 retry 2 retry 3 live resources grow with every recovery constant: one of each, always

Step-by-step #

  1. Allocate the bag per mount. Same lifecycle as the boundary reset: a remount means a new bag, and the old one is disposed on the way out.
  2. Register at creation. The helpers (interval, listen, observe, socket) make registration the same statement as creation, so there is no window where a resource exists without an owner.
  3. Dispose in reverse. A socket registered after the observer that resizes its canvas should be torn down first; reverse order gets that right by construction.
  4. Detach socket handlers before closing. close() fires onclose, which frequently re-enters application code that schedules a reconnect β€” resurrecting the very resource you were disposing.
  5. Make disposal idempotent. React can invoke cleanup more than once in development StrictMode; a #disposed flag makes the second call a no-op instead of a double-close error.
  6. Catch inside individual teardowns. One throwing disposer must not strand the ten registered after it. Report it as a cleanup-scoped crash so it is visible.
Resource Leaks as Teardown
setInterval Repeating work and retained closures clearInterval
setTimeout (long) Delayed write into a dead tree clearTimeout
window/document listener Handler pile-up, retained DOM removeEventListener with identical options
ResizeObserver / IntersectionObserver Retained target elements disconnect()
WebSocket / EventSource Open connection, server-side session Detach handlers, then close()
BroadcastChannel Cross-tab echoes into a dead tree close()
MediaQueryList listener Silent handler growth removeEventListener
Third-party SDK instance Whatever it holds, indefinitely Its own destroy()/unmount() β€” always look for one

The removeEventListener row hides a classic bug: the options object must match. addEventListener(t, h, { capture: true }) is not removed by removeEventListener(t, h). Routing every registration through the bag means the same options object is used for both calls automatically.

Registration order versus disposal order Two columns. The left column lists resources in registration order: interval, visibility listener, resize observer, socket. The right column lists them in disposal order: socket, resize observer, visibility listener, interval. Arrows connect matching entries, and a caption notes that reverse disposal keeps dependencies valid. registered disposed 1 Β· poll interval 2 Β· visibilitychange listener 3 Β· ResizeObserver 4 Β· WebSocket 4 Β· WebSocket closed 3 Β· observer disconnected 2 Β· listener removed 1 Β· interval cleared reverse order: nothing is torn down while something still depends on it Detached nodes across fifty retry cycles Two plotted lines over fifty cycles. The line without cleanup climbs steadily from a low baseline to a high value. The line with the disposer bag stays flat at the baseline throughout. high 0 crash-and-retry cycles β†’ no cleanup disposer bag β€” flat

Verification #

  1. Run the crash cycle fifty times. Arm a repeating fault, retry in a loop, then check performance.getEntriesByType('resource') growth and the network panel’s request rate β€” it must not climb.
  2. Count listeners directly. In DevTools Console, getEventListeners(document) before and after the cycle should report identical counts.
  3. Take two heap snapshots. One at baseline, one after fifty cycles, then filter by β€œDetached” β€” detached HTMLElement counts must not grow. Growth means something still references old DOM.
  4. Watch socket state. The Network β†’ WS panel should show exactly one open connection at all times, never a stack of closed-but-listed sockets accumulating.
  5. Deliberately break one disposer. Make the observer teardown throw; confirm the interval and socket are still cleaned up and a cleanup-scoped report appears β€” the error-tolerance property.

Third-party libraries and the resources you did not create #

The hardest leaks are the ones you never registered, because a library created them on your behalf. Charting libraries attach resize listeners, map widgets hold animation frames, analytics SDKs open long-lived connections, and none of that appears in your own code.

The rule that works is to treat every third-party instance as a resource in its own right: create it inside the disposer bag, and register whatever teardown the library offers β€” destroy(), unmount(), dispose(), remove() β€” at the same moment. When a library offers no teardown at all, that is a finding rather than a detail: it means every crash-and-retry cycle leaks whatever it holds, and the honest options are to wrap it in a sandbox that can be discarded wholesale or to replace it.

Verifying this is the same measurement as before, run against a page that uses the library heavily: fifty mount-crash-retry cycles, then compare listener counts and detached node counts with the baseline. A library that leaks shows up in minutes, and knowing which one it is turns an unexplainable memory graph into a scoped decision.

Frequently asked questions #

Does React clean up my timers automatically when a component unmounts?

No. React runs your effect cleanups; anything registered without a cleanup β€” an interval, a global listener, a socket β€” keeps running after the component disappears.

Why do leaks get worse specifically around error boundaries?

Because crash-and-retry mounts the same subtree repeatedly. A once-per-session leak becomes once-per-retry, and retries peak exactly when something is already failing.

Is it safe to throw inside a cleanup function?

It aborts the remaining cleanups in that effect. Wrap each teardown in its own try/catch and report failures rather than stranding everything registered after it.