First, prove that memory is retained
A healthy Node.js process can grow while warming caches, serving a busy window, or handling a large import. A leak is memory that remains reachable after the relevant work finishes and keeps pushing the baseline upward. Watch resident memory, V8 heap used, heap total, event-loop delay, restart count, and request volume together. If heap used rises after comparable load cycles and garbage collection never returns it near baseline, investigate.
--max-old-space-size can delay an outage while making the eventual heap snapshot larger and harder to inspect.Set an alert on the slope and on headroom, not only on a single absolute number. A small service may need a lower threshold than a large worker; what matters is how quickly it is consuming its allocation.
Capture comparable heap evidence safely
Reproduce the workload in staging when possible, then capture two heap snapshots: one after warm-up and one after repeated, representative work has completed. Compare retained size and object counts by constructor, retaining path, and allocation stack. A snapshot from an overloaded production process can be valuable, but treat it as sensitive: it may contain request data, tokens, or customer content.
node --inspect=0.0.0.0:9229 server.jsRecord the question beside each snapshot, such as “after warm-up” or “after 500 completed imports.” Compare retained objects, not just total heap size. Correlate profiles with request IDs and release versions using the practices in our Next.js observability guide.
Fix the retaining path, not the symptom
Most leaks are ordinary references that outlive their useful work: an unbounded Map, an event listener never removed, a timer that captures a large closure, a queue that keeps completed payloads, or a module-level array used as an accidental cache. The retaining path in the heap snapshot tells you which one is keeping the object alive.
Bound caches
Give every cache a size, TTL, eviction policy, and metric. Cache only data that is cheaper to retain than to recompute.
Release listeners
Pair subscriptions with cleanup, use once where appropriate, and set sensible listener limits to expose mistakes early.
Keep jobs small
Store IDs rather than large payloads in queues and clear completed-job retention deliberately.
For database-heavy paths, inspect connection and result lifecycles too. Our Node.js connection pooling guide covers bounded acquisition, release discipline, and metrics that often reveal adjacent resource leaks.
Prevent the next incident with production guardrails
Add a load test that repeats the formerly leaky workflow long enough to expose a rising baseline. Record a memory budget in the test, then fail when heap used does not stabilize after forced idle time. In production, ship gradual releases and compare memory slope, garbage-collection time, and restart rate by version.
- Expose heap-used and RSS metrics with release and route labels.
- Alert on sustained growth and shrinking memory headroom.
- Cap cache entries, queue retention, upload sizes, and concurrent work.
- Use a restart only as a recovery measure while the root cause is fixed.
- Document a snapshot runbook, including access controls and data handling.
CPU-heavy work can also magnify memory pressure when it blocks cleanup and request completion. Isolate it with the approach in our Node.js worker threads guide, while continuing to bound the data passed to each worker.
Node.js memory leak checklist
✓ Compare memory against workload, not a single number
✓ Confirm the post-GC baseline keeps rising
✓ Capture and protect comparable heap snapshots
✓ Follow retaining paths to the owning reference
✓ Bound caches, queues, listeners, and concurrency
✓ Add a regression test with a memory budget
✓ Monitor slope, headroom, GC time, and restarts
✓ Roll out fixes gradually and compare versions
Make Node.js services easier to operate
Endurance Softwares helps teams build observable, resilient Node.js and Next.js applications—from performance investigations to production-ready architecture and delivery practices.