It usually starts quietly. Your Node.js app runs fine after a deployment, and then, days later, it gets slower. Eventually the process crashes with an out-of-memory error, or your hosting platform restarts it. After the restart, everything's fine again, and the cycle begins anew. If that pattern sounds familiar, you may have a memory leak.
A memory leak happens when your application holds on to memory it no longer needs, so usage keeps creeping up instead of levelling off. They're frustrating because they rarely show up in quick tests. They appear after hours or days, under real traffic, and the symptoms (a slow app, random restarts) don't point directly at the cause.
The good news is that memory leaks follow patterns, and there's a methodical way to find them. This guide explains how to recognize one, what usually causes it, and how to track it down without guesswork.
How to tell it's a leak
Not every increase in memory is a leak. Node.js uses memory freely and cleans it up through garbage collection when needed, so usage naturally goes up and down. The sign of a leak is a steady upward trend that doesn't come back down, even after garbage collection has had time to run.
Typical signs include memory that rises over hours or days, a process that eventually crashes or gets killed for using too much memory, and slowdowns that get worse the longer the app has been running. Often the problem is clearer under heavier traffic, because whatever's leaking happens more often.
The usual suspects
Most leaks come from a few familiar causes.
Unbounded caches. A simple object or map that stores results to avoid repeating work seems harmless, until it grows forever. If you never remove old entries, the cache becomes a memory leak by design.
Event listeners that never get removed. Adding a listener inside a function that runs for every request, without removing it later, means you pile up listeners and everything they reference.
Timers and intervals that are never cleared. A repeating timer keeps everything in its scope alive until you stop it.
Closures that hold on to large objects. A function can keep references to variables from its surroundings, so a small callback might accidentally keep a huge object alive.
Global variables that accumulate data. Pushing items into a global array or object over time, such as logs, sessions, or request data, will slowly eat memory.
Large data kept in memory. Reading a whole big file or a huge query result into memory, instead of streaming it, creates large temporary objects, and if they stay referenced, they stay around.
Tools you'll want
You don't need anything fancy to get started.
process.memoryUsage() gives you quick numbers about heap and resident memory, which you can log periodically to see the trend.
The --inspect flag lets you connect Chrome DevTools to a running Node.js process. From there, you can take heap snapshots, which are detailed pictures of what's in memory at a particular moment, and record allocations over time.
Application performance monitoring services can chart memory usage over time and alert you when it trends up, which is useful for catching leaks early in production.
Heap profiling tools can also show which functions allocate the most memory, which helps narrow down suspicious code.
A step-by-step process
Here's a calm, methodical way to track down a leak.
1. Confirm that it's a leak. Watch memory over time under steady load. If it keeps climbing and doesn't settle, even after garbage collection, you've probably got one.
2. Reproduce it. Find out which actions or endpoints cause the growth. Ideally, reproduce it in a test environment where you can control the traffic. If you can trigger the leak on demand, the rest becomes much easier.
3. Take a baseline snapshot. Let the application warm up, then capture a heap snapshot. This is your "before" picture.
4. Apply load. Run the suspected operation many times, for example by sending a few thousand requests to the endpoint you're investigating.
5. Take a second snapshot and compare. DevTools can show you the difference between the two. Look for object types whose counts keep increasing and that don't make sense. Hundreds of thousands of copies of the same kind of object are a strong clue.
6. Look at what's holding on to them. The retainers view shows which objects hold references to the growing items. That chain of references usually points to the real cause, like a global array, a cache, or a listener.
7. Fix it, then verify. Make the change and repeat the test. Memory should now stay level. If it doesn't, there may be more than one leak.
What about quick fixes?
When production is struggling, you may be tempted to raise the memory limit or restart the process on a schedule. These can reduce the pain, and sometimes they're reasonable stopgaps while you investigate. But they hide the problem instead of solving it, and a bigger limit just delays the crash.
Treat them as temporary safety nets. The real fix is finding and removing what's holding the memory.
Preventing leaks in the first place
A few habits make leaks much less likely.
Give caches a maximum size and an expiry time, so they can't grow forever. Many projects use a library for this, so you don't have to build it yourself. Remove event listeners and clear timers when you no longer need them. Stream large files and big result sets instead of loading them all at once. Be careful with global state, and ask yourself whether anything you add to it will ever be removed.
It also helps to run load tests that last long enough to reveal slow growth, and to monitor memory in production with alerts, so you catch problems when they're small.
A simple example
Imagine a small API that caches user profiles in a plain object to avoid hitting the database. Every time a new user ID comes in, the profile is added to the object. Nothing ever removes anything. On day one, it's fast and everything looks great. After a few weeks, the object holds hundreds of thousands of profiles, memory climbs steadily, and the app starts to struggle.
When you take two heap snapshots and compare them, you'd see a huge number of profile objects growing between them, all held by one object in your code. That points straight at the cache. The fix is to limit its size and set an expiry, so old entries are removed. It's a small change, but without a methodical approach, it could take days to find.
Memory problems that aren't leaks
Sometimes memory is high because of how the app works, not because something is stuck. Processing a very large file, handling many large requests at once, or building big in-memory reports can all push memory up temporarily. These are design issues instead of leaks, and the solution is usually to stream data, process in smaller chunks, or limit concurrency.
A helpful test is to see whether memory comes back down after the heavy work finishes. If it does, you're probably dealing with a spike, not a leak. If it doesn't, keep investigating.
Wrapping up
Memory leaks feel mysterious, but they're usually caused by a small set of common patterns, and they can be tracked down with patience and the right tools. Confirm the leak, reproduce it, compare heap snapshots, and follow the references back to what's holding the memory. Then fix the cause, verify the fix, and add some safeguards so it doesn't happen again. Do it a couple of times, and you'll find that leaks stop being scary and start being just another bug to solve.