A Node.js service I maintain started restarting itself every few hours in production, memory climbing steadily until the container's limit killed it. No stack trace, no error, just a slow, quiet climb on the memory graph. Chasing that down taught me a process I now reach for every time, instead of the panic-driven guessing I used to do the first few times this happened.
The first step is the one I used to skip: proving the memory never comes back down. Node's garbage collector is lazy by design, so a sawtooth pattern where memory rises then drops after GC runs is normal, not a leak. What I look for specifically is a rising floor, the low points of that sawtooth trending upward over hours, which is what a real leak looks like on a heap usage graph pulled from process.memoryUsage() logged on an interval.
Once growth is confirmed, I take heap snapshots with the built-in inspector rather than reaching for a profiler upfront. Running node --inspect and connecting Chrome DevTools, taking a snapshot, generating load, then taking a second snapshot, and using the comparison view to sort by "Delta" surfaces which object types are accumulating. This is the single most useful view in the whole workflow, and I ignored it for too long in favor of reading raw heap dumps line by line.
node --inspect --max-old-space-size=512 server.js
// then chrome://inspect -> Memory tab -> take snapshot, generate load, take second snapshot
Before diving deep, I check the patterns that cause the majority of leaks I've actually found: event listeners added in a request handler without a corresponding removeListener, closures capturing large objects inside a setInterval that never gets cleared, and module-level arrays or Maps used as caches with no eviction policy. That last one was the actual culprit in the leak that prompted this whole process, a response cache Map that grew without bound because nobody had set a max size or TTL.
Finding the growing object type is only half the job, the retainer tree in DevTools shows what's holding a reference to it and preventing garbage collection. Working backward from a leaked object to its GC roots, usually through a chain of closures or an event emitter, tells you exactly which piece of code needs to stop holding that reference. This part is genuinely tedious, and I budget real time for it rather than expecting a five-minute answer.
For leaks that don't show up cleanly in manual snapshot comparison, clinic doctor's automated diagnosis has caught patterns I would have missed manually, particularly ones tied to event loop delay rather than pure heap growth. It's not a replacement for understanding the retainer tree yourself, but as a first pass on an unfamiliar codebase it points you toward the right area faster than starting cold.
Every leak fix I've shipped without first reproducing the growth locally under synthetic load has come back. Writing a small load-generation script that hits the suspect endpoint in a loop, watching memory climb locally, applying the fix, and confirming the floor stops rising before deploying is the step that turns a guess into a verified fix rather than a hopeful one.
The real lesson wasn't the debugging process itself, it was that most of these leaks were preventable with habits I now apply from the start: any cache gets a max size and TTL from day one, any setInterval or event listener registered outside a request-response cycle gets an explicit corresponding cleanup path, and I log heap usage on an interval in every service so a slow climb shows up in monitoring long before a container starts crash-looping.
After chasing this leak manually once, I didn't want to rely on remembering to check memory graphs periodically going forward. Adding a scheduled job that runs the service under synthetic load for a fixed window and asserts the RSS floor at the end is within a tolerance of the floor at the start turned this from something we notice reactively into something CI catches before a change ships. It's caught two smaller leaks since, both from the same event-listener pattern, before either reached production.
Not every leak traces back to application code. One case turned out to be a third-party logging library holding references to request objects for longer than it should have, which only showed up in the retainer tree as a chain running through a module I hadn't written. Filing that upstream and pinning an older, unaffected version in the meantime was the pragmatic fix, and it's a reminder that the retainer tree doesn't care whose code it's pointing at, it just tells you the truth.
Leaving the V8 heap limit at its default meant our leak had room to grow for hours before the container's own memory limit killed the process, which delayed detection. Explicitly setting --max-old-space-size closer to the container's actual memory ceiling, with headroom, made the process crash and restart faster once something did start leaking, trading a faster, more disruptive failure for a much shorter window of degraded performance before monitoring caught it.
Debugging a Node.js memory leak isn't mysterious once you have a repeatable process, but it does require resisting the urge to guess and restart the container as a stopgap forever. Confirm the growth is real, snapshot and diff, read the retainer tree, reproduce locally, and verify the fix under load before it ever reaches production again.