Memory Leak Hunting skill for Codex

Snapshot, repeat, compare, follow the retainer. Codex first separates real leaks from cache warm-up, load-proportional state and native fragmentation, then reproduces the leak with a repeated action and the three-snapshot technique. It names the accumulating type, walks its retainer path to the listener, timer, cache or global that holds it, and fixes that reference. Runtime recipes cover Chrome DevTools and memlab, Node, JVM with MAT and JFR, Python and Go.

  • Performance
  • $memory-leak-hunting
  • 113 lines
  • 3 files
  • ~1,650 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- memory-leak-hunting

Installs to ~/.agents/skills/memory-leak-hunting/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $memory-leak-hunting, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Finds and fixes memory leaks in browser apps, Node.js, JVM and Python services - confirms a real leak versus cache growth or fragmentation, reproduces it with a repeated action, compares heap snapshots to find what accumulates, walks retainer paths to the root that holds it, and proves the fix with a flat memory curve. Use when RSS or heap grows until OOM kills or restarts, a single-page app slows down after long use, detached DOM nodes pile up, or someone asks why memory keeps climbing.

Use it when

  • Memory climbs until OOM kills, restarts or swapping
  • A long-lived single-page app slows down over time
  • You have a heap dump or snapshot and need the retaining root
  • Celery, worker or job processes grow per task

Use something else when

  • Memory is high but stable and you want lower allocation churn or GC time: use jvm-tuning
  • You need Python allocation profiling rather than leak finding: use python-profiling

How to check the result

Repeating the leaking action several times more than before leaves the post-GC heap and the leaked type's instance count flat, and production memory stays level over a full traffic cycle.

The mistake it prevents

Taking a heap snapshot on a production instance already near its memory limit, which triggers the very OOM you are investigating.

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

--- name: memory-leak-hunting description: Finds and fixes memory leaks in browser apps, Node.js, JVM and Python services - confirms a real leak versus cache growth or fragmentation,… license: MIT ---

Done well means: you proved memory grows per repetition of a specific action (not just “over time”), named the object type that accumulates and the reference chain that keeps it alive, removed that reference, and showed memory plateauing across many more repetitions than before.

Is it a leak?

PatternLikely meaningNext step
Heap rises then drops after GC, saw-tooth with flat baselineNormal allocationNot a leak; maybe GC pressure (profile allocations instead)
Baseline after GC rises with each repetition of an action, never returnsLeakContinue below
Rises quickly then plateausCache or pool warming upCheck cache bounds; may be fine
Heap flat but RSS keeps growingNative memory, buffers, fragmentation, off-heapRuntime-specific tools (native memory tracking, memray —native, process.memoryUsage().external)
Grows with traffic, falls with trafficLoad-proportional state (sessions, queues)Capacity issue, not a leak

Always force GC before measuring the baseline (DevTools trash icon, node --expose-gc and global.gc(), jcmd <pid> GC.run, gc.collect()), or you compare garbage.

Workflow

- [ ] 1. Get the memory curve (metrics, container stats, DevTools memory timeline) and its time scale
- [ ] 2. Find an action that leaks: request type, route change, modal open/close, job run
- [ ] 3. Reproduce locally: warm up, snapshot, repeat action N times, GC, snapshot, repeat N, GC, snapshot
- [ ] 4. Compare snapshots: object types whose count grows by ~N (or a multiple) per round
- [ ] 5. Pick one leaked instance; read its retainer path to a GC root
- [ ] 6. Find the code that creates that reference; fix it; repeat step 3 and see counts stay flat
- [ ] 7. Watch production memory after deploy for a full cycle (day or traffic peak)

The three-snapshot technique

  1. Warm up (do the action a few times so caches, JIT and lazy modules settle).
  2. Snapshot A.
  3. Do the action N times (pick N like 10 or 50, easy to spot in counts). GC. Snapshot B.
  4. Do it N more times. GC. Snapshot C.
  5. Objects allocated between A and B that are still alive in C, and whose count grows by N per round, are the leak candidates. Ignore one-off growth.

Use a distinctive N: if Listener count grows by exactly 50 per round of 50 page opens, you have the culprit type.

Reading retainer paths

From a leaked object, follow retainers upward until you reach something long-lived: a module-level variable, a global, a singleton service, an event emitter, a timer, a closure captured by one of those. That link is the bug. Ignore internal/system edges ((system), weak references, WeakMap values keyed by live keys are not leaks).

The usual suspects

Leak sourceLooks likeFix
Event listeners not removed (DOM, EventEmitter, socket, store subscriptions)Growing listener arrays; detached DOM retained via closureRemove on teardown; AbortController signal for addEventListener; React effect cleanup
Timers and intervals never clearedClosures retained by timer listclearInterval/clearTimeout on teardown; unref() in Node for background timers
Unbounded caches and memoisationMap/dict grows with distinct keysLRU with max size and TTL; WeakMap when keyed by objects
Module-level arrays collecting per-request dataArrays of request/response objectsScope to the request; ship to a queue or logger instead
Detached DOM nodes held by JS“Detached” elements in browser snapshotsNull references on unmount; avoid storing elements in long-lived structures
Closures capturing large scopesSmall function retaining a big array/objectExtract the needed value before creating the closure
Promises that never settle, pending requests without timeoutsGrowing pending promise/callback countTimeouts and AbortSignal.timeout(); reject on shutdown
Per-request class definitions, regex or schema compilation in some librariesGrowing Code/class metadata (Metaspace in JVM)Create once at startup
ThreadLocal / context not cleared in pooled threads (JVM)Values retained by thread pool threadsremove() in finally
Native handles: unclosed files, sockets, DB cursors, streamsRSS grows, fd count growsusing/try-with-resources/with; close in finally

Production safety

  • Heap snapshots pause the process and can need as much extra memory as the heap itself. Taking one on an instance near its limit can trigger the OOM you are hunting. Prefer a canary or an instance removed from the load balancer, and get the user’s approval before touching production.
  • Heap dumps contain user data (tokens, emails, payloads). Treat them as sensitive: do not upload them to third-party tools or commit them; delete after analysis.
  • Automatic dump on OOM is cheap insurance: JVM -XX:+HeapDumpOnOutOfMemoryError, Node --heapsnapshot-near-heap-limit=2.

Runtime recipes

For exact commands per runtime (Chrome DevTools and memlab, Node inspector and --heapsnapshot-signal, JVM jcmd/JFR/Eclipse MAT, Python tracemalloc/memray/objgraph, Go heap profiles), read references/runtime-recipes.md once you know which runtime leaks.

Verification

  • Repeat the reproduction with 3-5x more iterations than before: the leaked type’s count stays flat between snapshots and the post-GC baseline plateaus.
  • Add a regression test where practical: a loop of the action with a heap-size or object-count assertion (memlab scenario for browsers, process.memoryUsage() delta with forced GC in Node, tracemalloc delta in Python), tolerant of noise.
  • In production, the memory graph over a full traffic cycle is flat or saw-tooth with a stable floor; restarts due to OOM stop.

Report template

## Memory leak: <service / app>
Symptom: heap +40 MB/hour, OOM restart every ~9 h (container limit 1 GB)
Repro: open and close the order drawer 50x; post-GC heap +6.1 MB per round
Leaked type: `OrderDrawerController` (+50 per round), retaining ~120 KB each
Retainer path: window -> eventBus.listeners['order:update'][n] -> closure -> controller
Root cause: subscription in mount() never unsubscribed (src/orders/drawer.ts:44)
Fix: unsubscribe in destroy(); AbortController for DOM listeners
Proof: 250 open/close cycles, post-GC heap flat (+/-0.3 MB); memlab scenario added
Production: watch memory for 24 h after deploy

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Memory Leak Hunting"
  short_description: "Snapshot, repeat, compare, follow the retainer"
  default_prompt: "$memory-leak-hunting Our Node service gets OOM-killed every 8 hours, find the memory leak"
references/runtime-recipes.md150 lines, 6.5 KB
# Runtime recipes for leak hunting

Read only the section for the runtime that leaks. Every recipe follows the same shape:
force GC, capture, repeat the action, capture again, compare, follow retainers.

## Browser (Chrome DevTools)

1. Open the page in a clean profile (extensions allocate too). DevTools > Memory.
2. Click the trash icon (collect garbage), take a **Heap snapshot** (A).
3. Repeat the action N times (navigate in the SPA and back, open/close a modal). GC.
   Snapshot B. Repeat N times. GC. Snapshot C.
4. Select C, Summary view, filter dropdown "Objects allocated between Snapshot A and
   Snapshot B". Sort by `# New` or Retained Size.
5. Type `Detached` in the class filter to list DOM nodes no longer in the document but
   still referenced from JS. Recent Chrome versions also offer a "Detached elements"
   profile type that lists them directly.
6. Click an instance; the Retainers pane shows the path. Look for your module names,
   event listener arrays, closures (`(closure)` / `system / Context`) and framework
   stores.

Other views:
- **Allocations on timeline**: blue bars that stay blue after GC are retained
  allocations; select a bar to see what was allocated at that moment.
- **Performance panel with "Memory" checked**: JS heap, DOM node and listener counts over
  time. Rising node or listener counts across repeated actions confirm a DOM leak quickly.

Automate with memlab (Meta, open source; needs network to install and Puppeteer's
Chromium):

```js
// scenario.js
module.exports = {
  url: () => 'http://localhost:5173/orders',
  action: async (page) => { await page.click('[data-testid="open-drawer"]'); },
  back: async (page) => { await page.click('[data-testid="close-drawer"]'); },
};
```

```bash
npx memlab run --scenario scenario.js     # reports leaked objects with retainer traces
```

Framework notes: React effects must return cleanup for subscriptions and timers;
Angular components should unsubscribe (`takeUntilDestroyed`); Vue `onUnmounted` for
manual listeners. Third-party widgets (maps, charts, editors) usually expose a `destroy()`
method that must be called on unmount.

## Node.js

```bash
node --inspect=127.0.0.1:9229 dist/server.js            # chrome://inspect > Memory tab
node --heapsnapshot-signal=SIGUSR2 dist/server.js        # kill -USR2 <pid> writes Heap.*.heapsnapshot
node --heapsnapshot-near-heap-limit=2 dist/server.js     # up to 2 snapshots as heap nears the limit
node --expose-gc dist/server.js                          # allows global.gc() in a debug route or script
```

```js
import v8 from 'node:v8';
global.gc?.();
const file = v8.writeHeapSnapshot();   // synchronous; blocks the event loop
console.log('snapshot', file, process.memoryUsage());
```

- Load `.heapsnapshot` files in Chrome DevTools Memory panel (Load button) and use the
  Comparison view between two snapshots.
- `process.memoryUsage()`: `heapUsed` growth = JS objects; `external`/`arrayBuffers` growth =
  Buffers and native-backed memory; `rss` growth with flat heap = native or fragmentation.
- Common Node leaks: EventEmitter listeners (watch for `MaxListenersExceededWarning`),
  per-request data pushed into module arrays, `setInterval` in request handlers, unbounded
  in-memory caches (`Map` keyed by user or URL), unconsumed HTTP response bodies with
  `fetch` (always read or cancel the body), AsyncLocalStorage stores held by long-lived
  callbacks.
- Snapshot size is roughly heap size; ensure disk space and memory headroom first.

## JVM (Java, Kotlin, Scala)

```bash
jcmd <pid> GC.class_histogram > histo-1.txt     # quick per-class instance counts (triggers full GC)
# ... repeat the action N times ...
jcmd <pid> GC.class_histogram > histo-2.txt
diff <(head -40 histo-1.txt) <(head -40 histo-2.txt)

jcmd <pid> GC.heap_dump /tmp/app.hprof          # live objects; large file, pauses the JVM
```

Startup flags worth having in every service:
`-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/`.

Analyse `.hprof` in Eclipse MAT (desktop app):
- **Leak Suspects** report for a first guess.
- **Dominator tree**: objects retaining the most memory.
- On a suspect: right click > Path to GC Roots > exclude weak/soft references.
- Typical roots: static collections, caches without eviction, `ThreadLocal` values in
  pooled threads, listeners registered on singletons, classloaders kept alive after
  redeploy (Metaspace growth).

Low-overhead option in production, JFR old-object sampling:

```bash
jcmd <pid> JFR.start name=leak settings=profile
# ... let it run through several leak cycles ...
jcmd <pid> JFR.dump name=leak filename=/tmp/leak.jfr path-to-gc-roots=true
jfr print --events jdk.OldObjectSample /tmp/leak.jfr | head -100
```

Native (off-heap) growth: start with `-XX:NativeMemoryTracking=summary`, then
`jcmd <pid> VM.native_memory baseline` and later `jcmd <pid> VM.native_memory summary.diff`.
Direct ByteBuffers, thread stacks (too many threads) and Metaspace are the usual sources.

## Python

```python
import gc, tracemalloc
tracemalloc.start(25)
gc.collect(); s1 = tracemalloc.take_snapshot()
for _ in range(50): handle_request(sample)
gc.collect(); s2 = tracemalloc.take_snapshot()
for stat in s2.compare_to(s1, "traceback")[:5]:
    print(stat); print("\n".join(stat.traceback.format()))
```

```python
import objgraph                     # pip install objgraph (graphviz needed for images)
objgraph.show_growth(limit=15)      # call twice around the repeated action
objgraph.show_backrefs(objgraph.by_type("Session")[-1], max_depth=6, filename="refs.png")
```

```bash
PYTHONMALLOC=malloc memray run -o leak.bin app.py
memray flamegraph --leaks leak.bin      # allocations never freed by the end of the run
memray attach <pid>                      # live process; needs gdb or lldb and ptrace rights
```

Common Python leaks: module-level dicts/lists used as caches, `functools.lru_cache` with
`maxsize=None` on methods (also retains `self`), reference cycles involving objects with
`__del__` in old code, C extensions not releasing memory, Django `DEBUG=True` storing
every query in `connection.queries`, Celery workers accumulating state (mitigate with
`--max-tasks-per-child` while fixing the cause).

## Go

```bash
go tool pprof -sample_index=inuse_space -diff_base=heap-1.pprof heap-2.pprof
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -40
```

Capture `/debug/pprof/heap` twice, several minutes apart under steady load, and diff
`inuse_space`. Growing goroutine counts are the most common Go leak (each holds its
stack and everything it references): look for goroutines parked on channel operations
with no `ctx.Done()` case. Details in the go-pprof skill.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem