Your Node.js process starts at 150 MB, and a few days later it’s at 1.5 GB, until it crashes with “JavaScript heap out of memory”, or the Linux OOM killer ends it. That’s a memory leak: objects that are no longer needed, but are still referenced, so the garbage collector can’t free them. Here’s how to confirm a leak, find it, and keep production stable meanwhile.

Heap vs RSS

setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({
    rss_mb: (m.rss / 1e6).toFixed(0),
    heap_used_mb: (m.heapUsed / 1e6).toFixed(0),
    heap_total_mb: (m.heapTotal / 1e6).toFixed(0),
    external_mb: (m.external / 1e6).toFixed(0),
  }));
}, 60000);
  • heapUsed growing steadily, and not dropping after garbage collection → a JavaScript object leak.
  • RSS growing while the heap is stable → native memory (Buffers, native addons, many concurrent requests), or fragmentation.

Memory that rises under load and falls back when traffic drops isn’t a leak. Watch the trend over hours or days. PM2 (pm2 monit) or Prometheus metrics (prom-client default metrics) make this easy. See observability basics.

Set a sensible heap limit

node --max-old-space-size=512 server.js       # MB

Set it below the container or VPS memory limit, so Node crashes with a clear error (and restarts) instead of the whole server swapping or the OOM killer picking a random process. See Linux OOM killer and Docker CPU and memory limits.

Take heap snapshots

A heap snapshot records every object in memory. Compare two snapshots taken some time apart, to see what grew.

Trigger snapshots on demand in production (they pause the process for seconds, and need roughly as much free memory as the heap):

const v8 = require("node:v8");
process.on("SIGUSR2", () => {
  const file = v8.writeHeapSnapshot(`/tmp/heap-${Date.now()}.heapsnapshot`);
  console.log("heap snapshot written to", file);
});
kill -USR2 <pid>       # snapshot 1, after warm-up
# … wait while memory grows (e.g. 30 minutes of traffic) …
kill -USR2 <pid>       # snapshot 2

Or start Node with --heapsnapshot-signal=SIGUSR2. Then copy the files to your computer (scp), and open them in Chrome DevTools → Memory → Load. Use the Comparison view: sort by “# Delta” or “Size Delta”, and look at retainers (what keeps the objects alive).

On a staging server you can also attach DevTools live, with node --inspect=127.0.0.1:9229, plus an SSH tunnel. See SSH tunnelling.

Common causes

Leak pattern Example
Unbounded caches A Map or object used as a cache, without size limits or TTL
Event listeners emitter.on(...) added per request, and never removed (watch for MaxListenersExceededWarning)
Global arrays Logs, metrics or requests pushed into arrays that only grow
Closures Callbacks holding references to large objects (requests, responses)
Timers setInterval per user/connection, never cleared
Unclosed resources Database connections, streams, sockets left open
Libraries Known leaks in specific versions. Check issues and update

Fixes: use LRU caches with a maximum size (lru-cache), remove listeners (once, off), clear timers, close resources in finally blocks, and keep request-scoped data out of module-level variables.

Stopgaps while you investigate

  • Restart on memory threshold: PM2’s max_memory_restart: "500M" restarts an instance before it gets dangerous. With cluster mode and graceful shutdown, users don’t notice. See Node.js cluster mode.
  • Scheduled restarts during quiet hours.
  • These hide the problem, so keep the investigation going.

Prevent regressions

Quick reference

Task How
Watch memory process.memoryUsage(), pm2 monit
Heap limit --max-old-space-size=<MB>
Snapshot v8.writeHeapSnapshot() / --heapsnapshot-signal
Compare Chrome DevTools → Memory → Comparison
Stopgap PM2 max_memory_restart
Usual culprits Caches, listeners, timers, globals

Related: PM2 process manager and Linux memory usage high.

  • Node.js
  • Performance
  • Troubleshooting