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:
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 observed | Confidence |
|---|---|
| One resource outlived one unmount | low β quite possibly an async close mid-flight |
| Several mount/unmount cycles left resources behind | medium |
| Every one of N cycles did | high |
removeEventListener called with the wrong function | high 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.
β² 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.
What it watches
All of it instrumented globally, with every patched global restored on shutdown().
setTimeout, setInterval, animation frames, idle callbacksaddEventListener, including the two silent failures of removalfetch β settled, aborted, or still in flightuseTrackedResource, where cleanup is a required argumentWhy 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 lives | Attributed |
|---|---|
| A plain function component | yes |
memo(β¦), forwardRef(β¦), and the two nested | yes |
A custom hook (useSomething) | no |
A class component's componentDidMount | no |
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.
occurrences count rather than a growing list.Honest about memory
The megabyte counter is the weakest signal here, and the design says so.
performance.memoryis 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
WeakMap,
records are bounded and evicted released-first, and there is a dedicated test across 100,000 resource
events.setTimeout is not a leakIt released itself. Conflating that
with an uncleared setInterval would bury the findings that matter.