Memory Leak Hunting skill for Claude Code
From growing heap to the exact retaining reference. Claude separates real leaks from warming caches by repeating one action with forced GC, then uses the three-snapshot comparison to find object types that grow per round. It follows the retainer chain to the first reference your code owns, such as a listener, timer, unbounded Map or uncleaned effect, fixes it, and reruns the same loop to show flat memory.
- name
memory-leak-hunting - size159 lines · ≈1,950 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Proves and fixes memory leaks in browsers and servers using the three-snapshot heap technique, retainer chains in Chrome DevTools and Node heap snapshots, detached DOM detection, MemLab scenarios, and equivalent tools for Python (tracemalloc, memray), Go (pprof), and the JVM (heap dumps). Use when memory grows until OOM or OOMKilled restarts, a single-page app slows down after long use, heap usage never returns to baseline after load, or a MaxListenersExceededWarning or detached elements appear. Try asking
- Our Node service's memory climbs until Kubernetes OOMKills it every day — find the leak
- The dashboard tab gets slower and uses 2 GB after a few hours open
- We get MaxListenersExceededWarning in production — what is leaking?
Use it when
- Memory grows until OOM or container restarts
- A single-page app degrades after long sessions
- Heap does not return to baseline after load ends
- Detached DOM nodes or listener warnings appear
Skip it when
- CPU is the problem, not memory — use node-profiling or python-profiling
- React components re-render too often but memory is flat — use react-rerenders
How to check the result
Repeating the same action loop after the fix leaves heap size flat across rounds after forced GC, with a zero delta for the previously growing constructor.
The common mistake it prevents
Taking heap snapshots without forcing garbage collection or with console-logged objects, so ordinary garbage and DevTools references look like a leak.
Done well means: growth is demonstrated with a repeatable action loop, a heap comparison identifies the object type that accumulates, its retainer chain names the exact reference keeping it alive, the fix removes that reference, and the same loop afterwards returns to a flat baseline.
Ground rules
- Distinguish a leak from normal behavior first: caches warming up, lazy initialization, and GC that has not run yet all look like growth. A leak grows per repeated action and survives forced GC.
- Measure with repetition. One action proves nothing; 10–50 repetitions of the same action with a forced GC before each snapshot proves accumulation.
- Find the retainer, not just the leaked object. “50,000
Orderobjects” is a symptom; “held bylistenersarray on the singletonEventBusvia closure inOrderWidget” is the cause. - Heap snapshots pause the process and can be large (roughly heap size). On production servers take the instance out of rotation and get explicit approval first; snapshots may contain user data and secrets — handle them as sensitive files.
Workflow
- [ ] 1. Confirm growth: memory over time across repeated identical actions, after forced GC
- [ ] 2. Identify the kind: JS heap, native/external (Buffers, ArrayBuffers), DOM, or RSS only
- [ ] 3. Three snapshots: baseline -> after N actions -> after N more actions
- [ ] 4. Compare: object types whose count grows by ~N per round
- [ ] 5. Follow retainers from a leaked instance up to a GC root
- [ ] 6. Fix the retaining reference; add cleanup
- [ ] 7. Re-run the same loop; confirm flat memory
1–2. Confirm and classify
Node:
setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({
rss: m.rss >> 20, heapUsed: m.heapUsed >> 20, external: m.external >> 20, arrayBuffers: m.arrayBuffers >> 20,
}));
}, 10_000).unref();
| Growing | Where to look |
|---|---|
heapUsed | JS objects: heap snapshots |
external / arrayBuffers | Buffers, streams not consumed, native addons |
rss only, heap flat | Native allocations, fragmentation, thread stacks, or the allocator not returning memory (check whether it plateaus) |
| Browser: JS heap in Performance monitor / Memory panel | Heap snapshots, detached DOM |
| Browser: DOM node count rising | Detached elements, unbounded lists |
Force GC for clean measurements: Node node --expose-gc then global.gc(); DevTools has
a “Collect garbage” button (trash icon) in the Memory and Performance panels.
3–4. Three-snapshot technique
Browser (Chrome DevTools > Memory > Heap snapshot):
- Load the page, do the action once (warm caches), collect garbage, take snapshot 1.
- Repeat the action N times (e.g., open and close the modal 10 times), collect garbage, take snapshot 2.
- Repeat N more times, collect garbage, take snapshot 3.
- Select snapshot 3, view “Comparison” against snapshot 2 (or “Objects allocated between snapshots 1 and 2” in the Summary view filter). Sort by ”# Delta” or “Size Delta”.
- Look for constructors whose delta is a multiple of N: your classes,
Detached HTMLDivElement, closures ((closure)), arrays, strings with recognizable content.
Filter “Detached” in the class filter to find DOM nodes removed from the document but still referenced from JS. Recent Chrome versions also offer a “Detached elements” profiling type in the Memory panel.
Node: take the same three snapshots via node --inspect and DevTools
(chrome://inspect > Memory), or programmatically:
import { writeHeapSnapshot } from "node:v8";
// call after global.gc(): writes Heap-<timestamp>.heapsnapshot in cwd; open in DevTools Memory tab
const file = writeHeapSnapshot();
node --heapsnapshot-signal=SIGUSR2 app.js lets you trigger snapshots with
kill -USR2 <pid>; --heapsnapshot-near-heap-limit=2 captures automatically before an
out-of-memory crash.
Automate browser scenarios with MemLab (Meta): write a scenario with url, action,
and back functions, run npx memlab run --scenario scenario.js, and it reports leaked
objects with retainer traces. Useful for CI regression checks on SPAs.
5. Read retainers
In the snapshot, click a leaked instance and read the Retainers pane bottom-up to a GC root (Window, global, a module-level variable, an active timer, an event listener list).
- Ignore weak references and internal
(system)/(compiled code)edges at first. - The first object in the chain you own (your module, class, or closure) is where the fix goes.
- Closures show as
contextedges: a function retains its entire lexical scope, so an inner callback can keep a large outer variable alive.
6. Common leak patterns
| Pattern | Fix |
|---|---|
| Event listener added on mount, never removed (window, document, emitters, sockets) | Remove in cleanup; use AbortController signal: addEventListener("resize", fn, { signal }) then controller.abort() |
setInterval / setTimeout loops never cleared | Store handles, clear on teardown |
Unbounded Map/object cache keyed by request, user, or URL | LRU with max size and TTL; WeakMap when keyed by objects whose lifetime you do not control |
| Subscriptions (RxJS, stores, WebSocket handlers) without unsubscribe | Unsubscribe in cleanup / takeUntil / effect return |
| React effect without cleanup | Return a cleanup from useEffect; abort fetches |
| Detached DOM held by a JS array or closure | Drop references when removing nodes; avoid caching DOM nodes globally |
| Global arrays used as logs/queues | Bound them or flush |
| Promises that never settle, each holding a closure | Timeouts with AbortSignal.timeout(ms); reject on teardown |
| Per-request data stored on long-lived objects (app.locals, singletons) | Keep request data in request scope / AsyncLocalStorage |
| Node streams not consumed or not destroyed | pipeline() from node:stream/promises; destroy on error |
MaxListenersExceededWarning | A listener added per request/operation; fix the add, do not raise the limit |
For the per-runtime catalog (Python, Go, JVM, browser frameworks) with tool commands, read references/leak-patterns.md when the leak is outside JavaScript or the common patterns above do not match.
7. Prove the fix
Repeat the exact loop from step 3 with the fix. Pass criteria: after forced GC, heap size at round 3 equals round 2 within noise (a few percent), and the leaked constructor’s delta is zero.
Gotchas
- DevTools itself retains objects: console-logged objects stay referenced. Clear the
console and avoid
console.log(obj)during measurements. - Development builds (React StrictMode, HMR, source maps) retain extra objects; confirm in a production build.
- Snapshots taken without GC show garbage as growth.
- A
WeakMaponly helps when keys are objects whose lifetime ends elsewhere; it does not make values collectable while keys live. - In Node containers, an OOMKilled pod may be heap limit vs. container limit mismatch
rather than a leak: set
--max-old-space-sizeto about 75% of the container memory and re-test. - Memory that climbs and plateaus is usually a cache or pool at its cap, not a leak; check configured max sizes.
Report template
**Symptom:** dashboard tab grows ~40 MB/hour; Chrome kills it after ~6 h
**Repro:** switch between Overview and Reports 20x, collect garbage, snapshot (x3)
**Growth:** +20 `ChartInstance`, +20 Detached HTMLCanvasElement per 20 switches
**Retainer chain:** window -> resizeHandlers (Array) -> closure in useChart.ts:31 -> ChartInstance -> canvas
**Fix:** effect cleanup removes the resize listener (AbortController) and calls chart.destroy()
**After:** heap flat at 38 MB across 3 rounds; detached canvas count 0 Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/leak-patterns.md
# Leak patterns and tools by runtime
## Browser frameworks
### React
- `useEffect` subscribing to window/document events, intervals, observers
(`ResizeObserver`, `IntersectionObserver`, `MutationObserver`) without returning a
cleanup.
- Third-party widgets (charts, maps, editors) created in an effect but never
`destroy()`ed on unmount.
- Module-level caches keyed by props or IDs that grow forever.
- Refs stored in global singletons (e.g., a registry of component instances).
- In development, StrictMode mounts effects twice; leaks that only appear in dev may be
missing cleanups that also leak in production on remount.
### Vue / Svelte / Angular
- Vue: manual `addEventListener` in `onMounted` without `onUnmounted` removal; global
event buses (`mitt`) with handlers never `off`'d.
- Angular: RxJS subscriptions without `takeUntilDestroyed()` / `async` pipe.
- Svelte: store subscriptions created manually (`store.subscribe`) without calling the
returned unsubscribe.
### SPAs in general
- Router keeps old views alive in a cache (`keep-alive`) without a max.
- Analytics or logging libraries buffering events when the network is offline.
- Infinite scroll appending DOM nodes forever: virtualize.
## Node.js
| Pattern | Detection | Fix |
|---|---|---|
| Listener per request on a shared emitter | `MaxListenersExceededWarning`, `emitter.listenerCount()` rising | Register once; or `once`; remove in `finally` |
| In-memory session or rate-limit store | Map size metric | External store (Redis) or LRU with TTL |
| Memoization without bounds (`memoize(fn)` on unbounded keys) | Heap snapshot shows huge Map | LRU (`lru-cache` with `max`/`ttl`) |
| Closures capturing `req`/`res` in long-lived timers or caches | Retainers show `IncomingMessage` | Copy needed fields, not the request |
| Unconsumed HTTP response bodies (undici/fetch) | `arrayBuffers`/sockets rising | Always consume or `body.cancel()` / `res.resume()` |
| Promise.race with timers never cleared | Timers count grows | Clear timer in `finally`, or `AbortSignal.timeout` |
| Large module-level arrays used as queues | Snapshot | Bound size; process and drop |
Tools:
- `node --inspect` + DevTools Memory tab (snapshots, allocation timeline, allocation
sampling).
- `v8.writeHeapSnapshot()`, `--heapsnapshot-signal`, `--heapsnapshot-near-heap-limit`.
- `v8.getHeapStatistics()` and `v8.getHeapSpaceStatistics()` for which space grows.
- `process.getActiveResourcesInfo()` lists active handles (timers, sockets) by type.
## Python
```python
import tracemalloc
tracemalloc.start(25) # keep 25 frames per allocation
snap1 = tracemalloc.take_snapshot()
run_action_n_times()
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "traceback")[:10]:
print(stat)
for line in stat.traceback.format()[-6:]:
print(" ", line)
```
- memray: `memray run -o out.bin app.py`, then `memray flamegraph --leaks out.bin` (run
with `PYTHONMALLOC=malloc` for accurate leak reports, per memray docs).
- objgraph: `objgraph.show_growth()` between rounds; `objgraph.show_backrefs(obj)` draws
retainers (needs graphviz).
- `gc.get_referrers(obj)` for ad-hoc retainer inspection; `gc.collect()` before measuring.
Common causes: `functools.lru_cache`/`cache` on methods (retains `self`), unbounded
module-level dicts, reference cycles involving objects with `__del__` (collected since
3.4 but delayed), C extensions not releasing buffers, pandas chained copies kept in a
list, logging handlers added on every call.
## Go
```bash
curl -s http://localhost:6060/debug/pprof/heap > heap1.pb.gz # with net/http/pprof imported
# ... run load ...
curl -s http://localhost:6060/debug/pprof/heap > heap2.pb.gz
go tool pprof -base heap1.pb.gz heap2.pb.gz # top, list <func>, web
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -50
```
- Goroutine leaks are the most common Go "memory leak": blocked senders/receivers,
missing context cancellation. Watch `runtime.NumGoroutine()`.
- Slices referencing large backing arrays (`small := big[:10]` keeps `big` alive): copy
with `slices.Clone`.
- `time.Ticker` not stopped; maps that only grow (maps never shrink their buckets —
recreate them periodically if they had a spike).
## JVM
```bash
jcmd <pid> GC.heap_dump /tmp/heap.hprof # explicit approval on production
jcmd <pid> GC.class_histogram | head -30 # quick count by class, compare over time
```
- Analyze dumps with Eclipse MAT (Leak Suspects report, dominator tree, path to GC roots
excluding weak references) or VisualVM.
- `-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps` to capture automatically.
- Common causes: static collections, `ThreadLocal` values in pooled threads never
removed, listeners/observers registered without removal, classloader leaks on
redeploy, unbounded caches (use Caffeine with maximumSize/expireAfter).
## Containers and OS-level
- Distinguish heap from RSS: a JVM or Node process with a heap limit near the container
limit gets OOMKilled without a heap leak. Leave 25%+ headroom for native memory.
- glibc malloc fragmentation can make RSS grow with a flat heap in multi-threaded
native-heavy services; `MALLOC_ARENA_MAX=2` or jemalloc are common mitigations — test
before adopting.
- Check `kubectl top pod`, container memory metrics, and `dmesg` / events for OOM kills
to confirm the failure mode.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.