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} />;
}
Step-by-step #
- 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.
- 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. - 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.
- Detach socket handlers before closing.
close()firesonclose, which frequently re-enters application code that schedules a reconnect β resurrecting the very resource you were disposing. - Make disposal idempotent. React can invoke cleanup more than once in development
StrictMode; a#disposedflag makes the second call a no-op instead of a double-close error. - 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.
Verification #
- 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. - Count listeners directly. In DevTools Console,
getEventListeners(document)before and after the cycle should report identical counts. - Take two heap snapshots. One at baseline, one after fifty cycles, then filter by βDetachedβ β detached
HTMLElementcounts must not grow. Growth means something still references old DOM. - 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.
- 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.
Related #
- State Reset & Cleanup Protocols β the contract this page implements
- Aborting In-Flight Fetches with AbortController on Boundary Reset β cancelling the network work the same reset should stop
- Managing State Reset After Uncaught Promise Rejections β rolling back the state those resources were mutating