A service that restarts itself every few days because memory keeps climbing has a leak, and the usual response is to increase the memory limit and move on. That works until the growth rate increases or the restart happens during traffic. Finding the actual cause is a defined procedure rather than a mystery.
What changed in 2026
- Production heap capture became routine. Taking snapshots from a running production process without stopping it became well-supported and normal practice.
- Continuous memory profiling matured. Always-on allocation profiling caught growth trends before they became incidents.
- Common patterns got documented. The recurring causes became well enough known that most investigations now confirm a known pattern rather than discovering a new one.
- Async context tracking improved. Better tooling for tracking asynchronous resource lifetimes made a category of leaks easier to attribute.
Confirm before hunting
Not all growth is a leak. Caches grow to a bound and stop. Connection pools grow under load and stay. Memory that climbs and then plateaus is working as designed.
A leak grows without bound. Establish that by watching heap usage over a sustained period under steady load, forcing garbage collection periodically to distinguish uncollected garbage from genuinely retained objects. If usage after collection keeps climbing, that is a leak.
The diagnostic procedure
| Step |
Action |
| 1 |
Reproduce under sustained representative load |
| 2 |
Take a heap snapshot after warm-up |
| 3 |
Run load for a meaningful interval |
| 4 |
Force garbage collection, take a second snapshot |
| 5 |
Compare snapshots; sort by retained size delta |
| 6 |
Inspect retainer paths for the largest growers |
| 7 |
Fix, then repeat to confirm |
The comparison is the technique that works. A single snapshot shows a large heap with no indication of what is abnormal. Two snapshots separated by load, diffed, show precisely what accumulated in between — and that list is usually short.
Retainer paths are where diagnosis happens. Knowing that ten thousand objects of a type accumulated tells you what leaked; the retainer path shows what is holding references to them, which tells you why. The fix is almost always at the retaining end.
The four common causes
Event listeners never removed. A listener registered per request on a long-lived emitter accumulates, and each closure retains everything in its scope. This is the most common cause by a wide margin.
Timers not cleared. An interval scheduled and never cleared keeps its callback and everything the callback closes over alive indefinitely.
Closures retaining more than expected. A callback holding a reference to a large object it does not use still prevents collection of that object. This one is subtle because the retaining code looks innocent.
Unbounded caches. A map used as a cache with no eviction is a leak with a respectable name. Any cache needs a size bound or an expiry.
Global collections accumulating request-scoped data is a variant of the last one and worth checking specifically.
Common mistakes
- Treating all growth as a leak. Caches and pools grow legitimately.
- One snapshot instead of two. Cannot distinguish normal from abnormal.
- Not forcing collection before the second snapshot. Uncollected garbage looks like a leak.
- Reading object counts without retainer paths. Tells you what, not why.
- Testing in development. Leaks frequently only appear at production request volumes.
- Raising the memory limit as the fix. Delays the failure rather than removing it.
FAQ
Can I snapshot production safely?
Taking a snapshot pauses the process briefly and produces a large file. Do it on one instance out of rotation where possible.
What about native memory?
Heap snapshots cover the JavaScript heap. Growth in native memory — buffers, native addons — needs different tooling and shows as process memory growing while heap stays flat.
How long should the load run between snapshots?
Long enough that the leak accumulates visibly above noise. Minutes for a fast leak, longer for a slow one.
Does this apply to other runtimes?
The procedure generalizes — two snapshots, diff, retainer paths. Tooling and common causes differ; the garbage-collected-language equivalent for JVM is in GC tuning for the JVM.
Where to go next
For CPU-side analysis, read reading flame graphs. For a different runtime's memory behaviour, GC tuning for the JVM, and for connection-related resource growth, connection pooling explained.