The Memory Leak That Slowly Kills Your Node.js Application

A Node process that runs fine in staging, passes load tests, and then dies in production after eighteen hours isn't hitting a bug in the usual sense. Nothing throws. No stack trace points at a broken function. Instead, heapUsed climbs on a slow, almost imperceptible slope until V8 runs out of room and the process exits with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. The frustrating part is that the code causing it usually looks correct — it just quietly prevents the garbage collector from doing its job, one retained reference at a time.

Garbage Collection Doesn't Mean What It Sounds Like It Means

V8 doesn't scan for "leaked" memory the way a static analyzer would. It only asks one question, repeatedly: is this object still reachable from a root — the global object, an active closure, a live call stack? If yes, it survives. If no, it's collected. A memory leak in a garbage-collected language is never really missing memory management. It's an object that's technically still reachable — through a reference nobody meant to keep — long after the application has stopped caring about it.

That reachability check happens across two generations. New Space holds freshly allocated, short-lived objects and gets swept constantly with a fast copying algorithm called Scavenge. Objects that survive a few of those Scavenge cycles get promoted into Old Space, which V8 only cleans with a slower mark-and-sweep (and periodically mark-and-compact) pass, because scanning the whole old generation on every allocation would tank throughput. Old Space is where leaks actually live — objects that survive multiple GC cycles get promoted there, and V8 only reclaims them during major GC cycles or once heap pressure forces the issue. A leak, structurally, is nothing more than an object that keeps qualifying for promotion into a region V8 is reluctant to scan aggressively.