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.

  • namememory-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

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.

~/.claude/skills/memory-leak-hunting/SKILL.md

--- name: memory-leak-hunting description: Proves and fixes memory leaks in browsers and servers using the three-snapshot heap technique, retainer chains in Chrome DevTools and Node… license: MIT ---

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 Order objects” is a symptom; “held by listeners array on the singleton EventBus via closure in OrderWidget” 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();
GrowingWhere to look
heapUsedJS objects: heap snapshots
external / arrayBuffersBuffers, streams not consumed, native addons
rss only, heap flatNative allocations, fragmentation, thread stacks, or the allocator not returning memory (check whether it plateaus)
Browser: JS heap in Performance monitor / Memory panelHeap snapshots, detached DOM
Browser: DOM node count risingDetached 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):

  1. Load the page, do the action once (warm caches), collect garbage, take snapshot 1.
  2. Repeat the action N times (e.g., open and close the modal 10 times), collect garbage, take snapshot 2.
  3. Repeat N more times, collect garbage, take snapshot 3.
  4. 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”.
  5. 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 context edges: a function retains its entire lexical scope, so an inner callback can keep a large outer variable alive.

6. Common leak patterns

PatternFix
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 clearedStore handles, clear on teardown
Unbounded Map/object cache keyed by request, user, or URLLRU with max size and TTL; WeakMap when keyed by objects whose lifetime you do not control
Subscriptions (RxJS, stores, WebSocket handlers) without unsubscribeUnsubscribe in cleanup / takeUntil / effect return
React effect without cleanupReturn a cleanup from useEffect; abort fetches
Detached DOM held by a JS array or closureDrop references when removing nodes; avoid caching DOM nodes globally
Global arrays used as logs/queuesBound them or flush
Promises that never settle, each holding a closureTimeouts 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 destroyedpipeline() from node:stream/promises; destroy on error
MaxListenersExceededWarningA 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 WeakMap only 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-size to 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 111 lines · 5.3 KB
# 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.

esc