react-memory-detective

Find what your components forgot to clean up.

Listeners, timers, sockets and observers that outlive the component that made them β€” named, attributed, and never overclaimed.

v0.1.0 0 runtime dependencies 9.4 KB gzip published with provenance MIT

The gap it fills

Chrome DevTools can tell you an object is still retained. It cannot tell you which component created it, in which effect, or that the matching clearInterval was never called. This connects the four things the browser keeps separate:

React component  β†’  the effect that ran  β†’  the resource it created  β†’  whether it was ever released

What a heap profiler gives you

Heap: 142 MB
Detached DOM tree
(array) Γ— 3,481
Retainers: closure β†’ closure β†’ …

What this gives you

β–² ChatPanel unmounted, but 1 Γ— websocket
  created by it is still active.

Component:   ChatPanel
Confidence:  high
  β€’ Still active: 1 Γ— websocket.
  β€’   wss://chat.example.com
      src/ChatPanel.tsx:47:12
  β€’ Every one of 20 cycles left it behind.
Next step:   close the socket in the
  effect's cleanup.

Set-up is two steps

Dev-time only. It refuses to run in a production build, and the compiler stays in your bundler β€” never in your bundle.

Install

npm install --save-dev react-memory-detective

Add the build plugin, then initialise

The plugin is what attributes resources to components. Skip it and the tool still tracks everything, but cannot tell you whose it is β€” see why.

vite.config.ts

import { memoryDetective } from
  "react-memory-detective/vite";

export default defineConfig({
  plugins: [memoryDetective(), react()],
});

babel.config.js β€” Next, CRA, Metro

module.exports = {
  plugins: [
    process.env.NODE_ENV !== "production" &&
      "react-memory-detective/babel",
  ].filter(Boolean),
};
// main.tsx
import { init } from "react-memory-detective";
if (process.env.NODE_ENV !== "production") init();

That is the whole setup. Mount and unmount a screen a few times and findings appear in the console.

It will never print β€œMEMORY LEAK DETECTED”

Garbage collection is not observable from a page. A resource still alive after unmount is evidence of retention, not proof of a leak β€” so every finding carries a confidence level, and confidence is earned by repetition rather than asserted.

What was observedConfidence
One resource outlived one unmountlow β€” quite possibly an async close mid-flight
Several mount/unmount cycles left resources behindmedium
Every one of N cycles didhigh
removeEventListener called with the wrong functionhigh immediately β€” the mechanism is certain

That last row is the exception, because no inference is involved. A cleanup like

return () => window.removeEventListener("resize", () => handler());

removes nothing at all β€” removeEventListener only detaches when the function reference, event type and capture flag all match. Both references pass through the instrumentation, so this is reported as fact. The wrong-capture-flag variant is caught separately, because it needs a different fix.

What it found in a real application

Before release it was pointed at Excalidraw, with a leak planted in one real component. The numbers below are that run.

229live resources at rest, in a real app
2findings β€” exactly the two planted bugs
0findings once the plant was removed
β–² ColorPickerComponent unmounted, but 2 Γ— interval, 2 Γ— event-listener created by it are still active.

Component:   ColorPickerComponent
Confidence:  high
  β€’ ColorPickerComponent unmounted, and 4 resources created by it are still active more than 1500ms later.
  β€’ Still active: 2 Γ— interval, 2 Γ— event-listener.
  β€’   setInterval(1000ms) β€” created at packages/excalidraw/components/ColorPicker/ColorPicker.tsx:275:5
  β€’   resize on window   β€” created at packages/excalidraw/components/ColorPicker/ColorPicker.tsx:279:12
  β€’ Every one of 12 mount/unmount cycles left resources behind. That is a pattern, not a race.
  β€’ Garbage collection cannot be observed from a page, so this is retention evidence, not proof of a leak.

Absolute monorepo paths shortened above for width. Twelve cycles of the unmodified app produced nothing at all; remove the planted leak and it returns to silence.

The same exercise found five defects in this package β€” after the suite was green and its own fixture app behaved perfectly. The worst: a mismatch finding that blamed the first listener registered for an event rather than the one actually stranded. The fixture app had one listener per event, so it was always right; real applications have several. Fixtures agree with their author, which is why running against real code is a release criterion here rather than a good intention. The full list is in the changelog.

What it watches

All of it instrumented globally, with every patched global restored on shutdown().

TimerssetTimeout, setInterval, animation frames, idle callbacks
Listenersevery addEventListener, including the two silent failures of removal
ConnectionsWebSocket, EventSource, BroadcastChannel, Worker
ObserversMutation, Resize, Intersection
Requestsfetch β€” settled, aborted, or still in flight
Anything elseuseTrackedResource, where cleanup is a required argument

Why a build step

Effects run after render, so at the moment a setInterval is created there is nothing in the runtime that says whose effect is running. Without the plugin a resource created in an ordinary useEffect has no owner β€” it is still tracked, but it is never blamed on a component, because picking whichever component happened to be mounted is the guesswork this tool exists to avoid. The plugin runs each component's effect bodies inside that component's scope.

Where the effect livesAttributed
A plain function componentyes
memo(…), forwardRef(…), and the two nestedyes
A custom hook (useSomething)no
A class component's componentDidMountno

Measured on Excalidraw β€” 218 component files, 111 effect call sites β€” the plugin instruments 89% of them. The custom-hook gap is the one worth knowing about: closing it needs the owner captured during render rather than during the effect, and that is not in 0.1.0.

One problem is one finding

Ten leaking cycles of one interval are one finding seen ten times, not ten findings. An early version produced 52 findings for three bugs β€” unreadable exactly when it matters most.

Keyed by problemComponent, resource type and source location β€” with an occurrences count rather than a growing list.
Superseded, not stackedWhen repetition proves a leak, the earlier low-confidence suspicion is dropped rather than listed beside it.
One cause, one reportA retained listener that a mismatch already explains is reported once β€” as the mismatch, which is the finding that names the fix.
Silent when cleanNothing actionable means no output. A tool that cries wolf is uninstalled after the second false alarm.

Honest about memory

The megabyte counter is the weakest signal here, and the design says so.

  • performance.memory is Chrome and Edge only, non-standard, and deliberately quantised.
  • measureUserAgentSpecificMemory() needs the page to be cross-origin isolated (COOP + COEP), which most applications are not.
  • Firefox and Safari have neither.

So the resource registry is the spine. β€œThis component created a setInterval and no clearInterval was ever observed for that handle” is exact, universal, and needs no memory API. Where a figure is available it is reported as supporting evidence; where it is not, the tool says so and carries on. A sample with no bytes reports unavailable rather than naming an API, because implying a figure exists and was withheld would be its own small dishonesty.

The feasibility report classifies every capability as reliable, inferred, or impossible β€” and it was written before any of the code.

Why you can trust the output

Ownership is claimed, never guessedA resource created inside a tracked scope is owned. One created while some component merely happened to be mounted has no owner, and the tool says so.
The tool must not be the leakObject handles live in a WeakMap, records are bounded and evicted released-first, and there is a dedicated test across 100,000 resource events.
StrictMode handled first, not lastIts simulated unmount produces exactly the shape a leak detector looks for. Without special handling, every component in every StrictMode app would be reported.
A fired setTimeout is not a leakIt released itself. Conflating that with an uncleared setInterval would bury the findings that matter.
Nothing leaves the browserNo telemetry, no network, no storage β€” which matters more than usual here, because memory debugging touches tokens and user data.
Released from CI, with provenanceNever from a laptop. A guard inspects the packed tarball and refuses to publish if the manifest drifts or any runtime dependency appears.
Known limitations, written down rather than discovered by you: effects inside custom hooks and class components are not attributed β€” tracked, but not blamed on a component. Retention is what is observable from a page; collection is not. And memory figures are supporting evidence, never the finding.