David John Thammineni
Performance10 Jul 20267 min read

The Dashboard That Died Every Night: Hunting React Memory Leaks

The Dashboard That Died Every Night: Hunting React Memory Leaks

Memory leaks are invisible in the demo, invisible in code review, invisible in your test suite — and then very visible in week two of production, when someone's tab has been open for 40 hours. Most React apps get away with leaking because users navigate away before it matters. The apps that can't get away with it are the ones that never reload: dashboards, kiosks, trading screens, monitoring walls.

Why React leaks feel different

You rarely leak by forgetting free(). You leak by keeping a reference alive so the garbage collector never gets permission to collect. In React apps, the references that outlive their welcome cluster into five patterns — and each one has a signature you can learn to spot in a heap snapshot.

How one retained listener keeps an entire component tree alive

Pattern 1: subscriptions without cleanup

The classic, still the most common. Every addEventListener, setInterval, socket handler, or SDK subscription made in an effect needs a symmetric teardown:

tsx
useEffect(() => {
  const onResize = () => setCols(computeCols(window.innerWidth));
  window.addEventListener("resize", onResize);
  const id = setInterval(pollDockStatus, 15_000);
  return () => {
    window.removeEventListener("resize", onResize); // ✅ symmetric
    clearInterval(id);
  };
}, []);

The insidious part isn't the interval itself — it's what the closure retains. A leaked listener holds its closure; the closure holds component state; the state holds the props; the props hold... In our heap snapshots, one leaked resize listener retained an entire unmounted route, 6MB at a time.

Pattern 2: module-level caches that only grow

Our 3am culprit. The dashboard cached shipment detail lookups in a module-level Map to avoid refetching as views cycled:

ts
const shipmentCache = new Map<string, ShipmentDetail>(); // ❌ grows forever

A distribution centre processes tens of thousands of shipments a day. The cache had no eviction, and a Map at module scope is a GC root — nothing it holds can ever be collected. Every cycle of the wallboard added entries. The fix was boring and completely effective — a tiny LRU:

ts
const shipmentCache = new LRUCache<string, ShipmentDetail>({
  max: 500,
  ttl: 10 * 60 * 1000,
});

Any unbounded collection you write is a leak with a delivery date. Map for identity-keyed metadata has a leak-free alternative: WeakMap, which lets keys be collected.

Pattern 3: detached DOM held by refs and libraries

Imperative libraries — charts, maps, video players — attach to a DOM node and keep internal references to it. Unmount the React component without calling the library's own destroy method, and the node leaves the document but not the heap: a detached DOM tree.

tsx
useEffect(() => {
  const chart = echarts.init(ref.current);
  chart.setOption(buildOptions(data));
  return () => chart.dispose(); // ✅ without this, the canvas + tree leak
}, []);

In DevTools, search a heap snapshot for Detached — detached nodes are the smoking gun of this pattern, and the retainer chain tells you which library is holding them.

Pattern 4: closures captured by long-lived things

Promise chains that never settle, AbortControllers never aborted, debounced functions holding their last arguments, analytics queues holding event payloads. Individually small; multiplied by a re-rendering app, large. The rule: anything with a lifetime longer than a component must not capture that component's scope — pass it minimal data, not closures.

Pattern 5: the leak that isn't yours

Session-replay snippets, chat widgets, A/B testing SDKs. On the logistics dashboard, a vendor tag was retaining every DOM mutation record "for replay." We couldn't fix its code; we could exclude the kiosk build from loading it. Always test with third-party scripts disabled before burning days on your own code.

The forensic loop

The process that found our leak, reusable verbatim:

  1. Prove it and measure the rate. performance.memory sampled every minute, charted. Ours was a clean 40MB/hour line — a steady slope means a leak on a timer or event; a step function means a leak per user action.
  2. Three-snapshot technique in Chrome DevTools: snapshot → let the suspect flow run (for us: one full wallboard cycle) → snapshot → run it again → snapshot. Compare 2→3, filtered to objects allocated between 1 and 2 that are still alive — that intersection is your leak, with the noise of one-off allocations removed.
  3. Read retainer chains bottom-up. Pick the biggest retained object, walk up until you hit something you recognize — a variable name, a component, a library namespace. The first recognizable frame is usually the fix site.
  4. Fix, then re-run step 1. Leaks come in layers; ours had the Map and a disposed-less chart. The slope tells you when you're actually done.

The wallboards have now run for months without a nightly death. The postmortem's best line came from the night supervisor who'd been rebooting screens manually at 3am: "So the fix was... you stopped keeping everything?" Yes. That's the whole discipline, honestly.

Keep Reading