← All writing
BACKEND DEVELOPMENT8 min read

Node.js Memory Leak in Production: A Field Diagnosis

A production Node.js service climbing to an OOM kill every few hours. How to find the leak with heap snapshots, and the four patterns that cause most of them.

The symptom is always the same. A service runs fine for a few hours, memory climbs in a straight line that never comes back down, and then the process is killed and restarted. Requests fail during the restart window, and because it recovers on its own, it sits in the backlog as 'intermittent' for weeks.

This is a walkthrough of how to actually find one, and the four causes that account for most of them in practice.

First: confirm it is a leak

Not every rising memory graph is a leak. Node will happily let the heap grow toward its limit before collecting aggressively, so growth alone proves nothing. What proves a leak is that memory retained after a full garbage collection keeps rising across cycles.

javascript
// Log the shape of the heap on an interval. If heapUsed after GC
// trends upward over hours, the process is retaining, not just growing.
setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({
    evt: "mem",
    rss: Math.round(m.rss / 1048576),
    heapTotal: Math.round(m.heapTotal / 1048576),
    heapUsed: Math.round(m.heapUsed / 1048576),
    external: Math.round(m.external / 1048576),
    // Buffers live outside the JS heap — a leak here looks very
    // different from an object leak and is easy to misread.
    arrayBuffers: Math.round(m.arrayBuffers / 1048576),
  }));
}, 30_000);

Read the ratio, not just the total. If heapUsed is flat but rss and arrayBuffers climb, you are leaking buffers or native resources, not JavaScript objects — and the heap snapshot workflow below will show you almost nothing. That misdirection costs teams days.

Taking a heap snapshot from a live process

Modern Node can write a snapshot on demand, without a debugger attached and without restarting. Take one early in the process lifetime and one after memory has climbed, then compare.

javascript
import { writeHeapSnapshot } from "node:v8";

// Trigger deliberately — behind an authenticated admin route, or on a
// signal. Never leave this on an unauthenticated endpoint: a snapshot
// contains live application data.
process.on("SIGUSR2", () => {
  const file = writeHeapSnapshot();
  console.log(JSON.stringify({ evt: "heapdump", file }));
});
bash
# Snapshot early, wait for the climb, snapshot again.
kill -SIGUSR2 <pid>     # baseline, shortly after start
sleep 3600
kill -SIGUSR2 <pid>     # after ~1GB of growth

# Pull both files locally and load them into Chrome DevTools:
# More tools -> Memory -> Load. Select the second snapshot, then
# switch the dropdown to "Comparison" against the first.

In the comparison view, sort by 'Delta' on retained size. Ignore the constructors you expect to grow. The leak is usually a single entry with a delta in the tens or hundreds of thousands of objects, and its retaining path — shown in the bottom pane — tells you exactly which reference is holding it alive.

The four causes worth checking first

1. The unbounded module-level cache

By far the most common. Someone adds a Map keyed by user, session or request ID to avoid a database round-trip. It works. Nothing ever removes entries, and the key space is effectively infinite.

typescript
// Leaks: every distinct key is retained for the life of the process.
const cache = new Map<string, Session>();

export function getSession(id: string) {
  let session = cache.get(id);
  if (!session) {
    session = loadSession(id);
    cache.set(id, session);
  }
  return session;
}

The fix is a bound, not a bigger machine. Either an LRU with a hard entry cap, or a TTL, or both. If the cached value is only needed while something else holds the key, a WeakMap lets the entry be collected with it — but note that WeakMap only helps when the key is an object, which rules out the common string-ID case.

typescript
// Bounded: oldest entry is evicted once the cap is reached.
const MAX = 5_000;
const cache = new Map<string, Session>();

function remember(id: string, session: Session) {
  if (cache.size >= MAX) {
    // Map preserves insertion order, so the first key is the oldest.
    cache.delete(cache.keys().next().value!);
  }
  cache.set(id, session);
}

2. Listeners added per request

An event listener registered against a long-lived emitter inside a per-request handler accumulates one entry per request, and each closure retains everything in its scope — including the request and response objects.

javascript
// Leaks: 'db' outlives the request, so every handler ever run
// stays reachable, along with its captured req/res.
app.get("/orders", (req, res) => {
  db.on("error", (err) => res.status(500).send(err.message));
  // ...
});

// Fixed: bind for the lifetime of the request only.
app.get("/orders", (req, res) => {
  const onError = (err) => res.status(500).send(err.message);
  db.once("error", onError);
  res.on("close", () => db.off("error", onError));
  // ...
});

Node warns about this at eleven listeners on a single emitter, but the warning goes to stderr and is very easy to miss in a busy log. Treat MaxListenersExceededWarning as an error in CI rather than noise in production.

3. Timers and intervals that outlive their owner

A setInterval created per connection, per job or per request, never cleared, keeps its callback and everything the callback closes over alive forever. The same applies to a long setTimeout chain that reschedules itself.

Every timer created outside module initialisation needs an owner and a clear path to being cleared — usually on the same event that ends the thing it belongs to.

4. Streams that are never consumed or destroyed

An HTTP response body, a file read stream or an upstream fetch that is abandoned without being consumed or destroyed keeps its internal buffers. This is the case that shows up as growing arrayBuffers and external memory while the JS heap stays flat — which is why the ratio check at the start matters.

javascript
// Leaks: the body is never read, so its buffers are never released.
const response = await fetch(url);
if (!response.ok) return null;

// Fixed: always settle the body, on every path out of the function.
const response = await fetch(url);
if (!response.ok) {
  await response.body?.cancel();
  return null;
}

Buying time while you fix it

A restart policy is not a fix, but a service that is failing every three hours needs to stop failing today. Two mitigations are legitimate as short-term measures:

  • Set --max-old-space-size explicitly below the container limit, so V8 collects hard before the kernel OOM killer arrives. An in-process failure is far more debuggable than a SIGKILL.
  • Add a memory-based health check that fails readiness above a threshold, letting the orchestrator drain and recycle the instance gracefully instead of dropping live requests.

Both of these turn a hard outage into a slow one. Neither removes the reason memory is climbing, and it is worth writing that down in the ticket before someone marks it resolved.

Keeping it from coming back

Once fixed, the cheapest insurance is a soak test: run the service under representative load for an hour in CI and fail the build if retained heap after GC has grown beyond a threshold. It catches the next unbounded cache before it reaches production, which is the only time this class of bug is cheap to fix.

The other habit worth forming is treating every module-level mutable collection as something that needs a documented bound. Not because caching is wrong, but because an unbounded cache is a memory leak with better branding.

Frequently asked questions

How do I tell a memory leak from normal Node heap growth?
Normal growth plateaus. A leak keeps rising across garbage collection cycles. Log heapUsed every 30 seconds over several hours — if the post-GC floor trends upward rather than returning to a stable baseline, memory is being retained.
Can I take a heap snapshot without restarting the process?
Yes. v8.writeHeapSnapshot() writes a snapshot from the running process. Trigger it on a signal such as SIGUSR2 or behind an authenticated admin route — never expose it publicly, as snapshots contain live application data.
Why does rss keep growing when heapUsed is flat?
The leak is outside the JavaScript heap — typically Buffers, unconsumed streams or native addons. Watch external and arrayBuffers in process.memoryUsage(); heap snapshots will show very little for this class of leak.
Does increasing --max-old-space-size fix a memory leak?
No. It delays the crash and gives you a longer window to capture diagnostics, which is useful during an incident. The retention is still there and the process will still die, just later.

Working on something like this?

I take on product engineering, growth architecture and AI integration work.

mr@mrva.com

Keep reading