Memory leaks in long-running services are slow-motion disasters — memory climbs for hours or days until the process crashes or the OOM killer intervenes. The fix is rarely complicated, but finding it requires systematic profiling. Skipping straight to guessing and "optimizing" random code almost never works.
What changed in 2026
- Node.js 22 includes built-in
--heap-prof and improved v8.writeHeapSnapshot() for programmatic snapshots.
- Python 3.13's
tracemalloc integration with popular profilers (Memray, Fil) is better than ever; Memray from Bloomberg is the go-to Python memory profiler.
- Go 1.23 pprof remains the standard; the flame graph visualizations in the web UI are much improved.
- eBPF-based profilers (Parca, Polar Signals) can profile memory allocation in production without code changes — becoming standard in Kubernetes environments.
- Container memory metrics via cgroups v2 are accurate and easy to graph; most teams now have RSS/heap dashboards before they encounter a leak.
Step 1 — confirm the leak
Before profiling, confirm the behavior is actually a leak and not a legitimate growth pattern.
# Check RSS growth over time for process PID 12345
watch -n 5 "ps -o pid,rss,vsz -p 12345"
# In Docker, inspect container memory
docker stats my-container
A leak shows monotonically increasing RSS with no plateau. Normal processes grow then plateau. If memory is released after a spike, it is not a leak.
Node.js — heap snapshots
// server.js — programmatic heap snapshot
const v8 = require('node:v8');
const fs = require('node:fs');
// Take a snapshot (writes to CWD)
process.on('SIGUSR2', () => {
const filename = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${filename}`);
});
Then:
- Start the server:
node --expose-gc server.js
- Send load to the application.
- Send
kill -USR2 <pid> to take snapshot 1.
- Continue load.
- Take snapshot 2.
- Open Chrome DevTools → Memory → Load snapshot 1 and 2 → use Comparison view.
Look for objects that grow between snapshots — strings, arrays, EventEmitters, closures.
Common Node.js leak patterns:
// BAD: listener accumulation (EventEmitter)
emitter.on('data', handler); // called in a loop → listener count grows
// GOOD: remove listeners when done
emitter.off('data', handler);
// Or: emitter.once() for one-shot handlers
// BAD: growing Map/object cache with no eviction
const cache = new Map();
function process(key, val) {
cache.set(key, val); // never pruned
}
// GOOD: bounded cache with LRU eviction (use lru-cache package)
Python — tracemalloc and Memray
import tracemalloc
tracemalloc.start()
# ... run the suspect code ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
For production profiling, Memray is faster and more practical:
pip install memray
memray run -o output.bin my_script.py
memray flamegraph output.bin
The flamegraph shows exactly which call stack allocated the most memory.
Common Python leak patterns:
- Global lists or dicts that accumulate references (
append in a loop, never cleared).
- Circular references with
__del__ methods (pre-CPython 3.4 era, but still encountered in legacy code).
- Unclosed file handles or database connections.
- Class-level attributes mutated instead of instance attributes.
Go — pprof
import _ "net/http/pprof"
import "net/http"
// Add to main()
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
Then capture and analyze:
# Capture heap profile
go tool pprof http://localhost:6060/debug/pprof/heap
# In pprof interactive mode:
(pprof) top10 # top allocators
(pprof) list MyFunc # source-level breakdown
(pprof) web # flame graph in browser (requires graphviz)
Common Go leak patterns:
- Goroutine leaks (goroutines blocked on a channel nobody reads → they hold references).
time.Ticker not stopped after use.
http.Response.Body not closed.
- Slice retention — taking a small slice of a huge backing array keeps the array alive.
// BAD: goroutine leak
go func() {
ch <- result // if nobody reads ch, goroutine hangs forever
}()
// CHECK goroutine count
import "runtime"
log.Printf("goroutines: %d", runtime.NumGoroutine())
Leak debugging workflow
| Step |
Action |
| 1. Confirm |
Graph RSS/heap over time; ensure monotonic growth |
| 2. Isolate |
Reproduce under controlled load; reduce to minimal repro |
| 3. Profile |
Take heap snapshots before and after load |
| 4. Diff |
Identify which object type or call site is growing |
| 5. Fix |
Remove unbounded accumulation, add eviction, close handles |
| 6. Verify |
Rerun under load; confirm heap stabilizes |
How to pick your profiling tool
| Runtime |
Tool |
Notes |
| Node.js |
Chrome DevTools heap snapshot |
Best diff view for JS objects |
| Node.js |
clinic.js (NearForm) |
Flame graphs + memory doctor |
| Python |
Memray |
Best for line-level allocation flamegraphs |
| Python |
tracemalloc |
Built-in, no deps |
| Go |
pprof |
Built-in, production-safe |
| Any (Linux) |
Valgrind Massif |
Native C/C++ and FFI leaks |
| Any (Kubernetes) |
Parca / Polar Signals |
Continuous eBPF profiling |
Common mistakes
Taking a single snapshot. One snapshot shows what exists; it takes a diff between before-load and after-load to see what is growing.
Profiling only in development. Leaks often only manifest under production traffic patterns. Use sampling profilers (pprof, eBPF) that are safe to run in production.
Fixing the wrong thing. A large heap is not always a leak. If it plateaus, the application is just using memory. A leak is specifically unbounded growth.
Restarting as the solution. Scheduled restarts mask the leak in production but never fix it. They also cause downtime and hide real signal.
What to skip
- Manual memory counting without tooling — you cannot find a leak by reading code alone.
- Heap dumps on a live high-traffic service without testing the overhead first — a full V8 heap dump pauses the process.
- Over-optimizing memory usage in a service that has a stable plateau — stable high memory is not a leak and optimizing it has a low return.
FAQ
How much memory growth is a leak vs normal?
If memory grows without bound and never plateaus under constant load, it is a leak. If it grows to a level proportional to the data being processed and then stays flat, it is not.
Can a memory leak cause a crash?
Yes — the OOM killer on Linux terminates the process, or the container is OOMKilled. In Kubernetes, set resource limits and watch for OOMKilled restarts: kubectl describe pod shows the reason.
Are garbage collected languages immune to leaks?
No. GC only frees unreachable objects. If your code holds references to objects indefinitely (even unintentionally), the GC cannot collect them.
How do I find a leak in a library I do not control?
Profile with the call stack and look for the library frames allocating memory. File an issue with the heap diff or flamegraph as evidence. Pin to an older version if the leak is a regression.
Where to go next