Memory leaks in production Node.js applications are some of the hardest bugs to track down. The process behaves fine for hours, then slowly creeps toward the heap limit, response latencies climb, and eventually the process is OOM-killed. This guide walks through how V8 actually manages memory, why leaks happen, and the tooling you need to diagnose them with confidence.

Why Memory Management Matters in Node.js

Node.js is single-threaded for JavaScript execution. That means every millisecond the garbage collector pauses your process is a millisecond no requests are being served. Unlike a multi-threaded runtime where one thread can stall while others keep working, a long GC pause in Node.js directly translates into tail latency — the same event loop that serves your requests is what gets stalled. Worse, if memory growth is unbounded, the V8 heap eventually hits its hard ceiling (1.7 GB on 32-bit, ~4 GB by default on 64-bit) and the process crashes.

Node.js memory management

Understanding memory is therefore not just an optimization concern - it is a reliability concern. The good news: V8’s memory model is well-documented, and Node.js ships with first-class tooling to inspect it.

The V8 Memory Model

V8 - the JavaScript engine Node.js is built on - divides memory into a resident set that includes the heap, the stack, and a few specialized regions. The heap is where almost everything you care about lives. (For a closer look at how V8 compiles and optimizes the code that produces these allocations, see V8 JIT optimization techniques.)

Stack vs Heap

flowchart LR
    A[V8 Process Memory] --> B[Stack]
    A --> C[Heap]
    A --> D[Code Space]
    A --> E[External Memory]

    B --> B1[Primitives: number, boolean]
    B --> B2[References / function frames]

    C --> C1[Objects]
    C --> C2[Arrays]
    C --> C3[Closures]
    C --> C4[Strings - large]

    E --> E1[Buffers]
    E --> E2[C++ addon allocations]
  • Stack - Fixed-size, fast, LIFO. Stores primitive values held by local variables and the call frames themselves. Memory is reclaimed when a function returns. You almost never debug stack issues - the only common one is a RangeError: Maximum call stack size exceeded from unbounded recursion.
  • Heap - Dynamically sized, managed by the garbage collector. Stores every object, array, closure, and most strings. This is where leaks live.
  • External memory - Allocations made outside V8’s heap, primarily Buffer data and memory held by native addons. Reported under external and arrayBuffers in process.memoryUsage().

Heap Generations

V8 uses a generational garbage collector, built on a classic observation: most objects die young. A request handler allocates a request object, a response body, a few intermediate strings - all dead by the time the handler returns. A few objects, like the database connection pool or the LRU cache, live for the entire process lifetime.

V8 exploits this by splitting the heap into two generations and applying different collection strategies to each.

flowchart TB
    subgraph Heap[V8 Managed Heap]
        direction TB
        subgraph Young[Young Generation - New Space ~16MB]
            From[From Space]
            To[To Space]
        end
        subgraph Old[Old Generation]
            OldPointer[Old Pointer Space]
            OldData[Old Data Space]
            LargeObj[Large Object Space<br/>objects > 1MB]
            CodeSpace[Code Space<br/>JIT-compiled code]
        end
    end

    Young -->|Survived 2 minor GCs| Old
    style Young fill:#1f2a44,stroke:#7aa2f7,color:#e6e6e6
    style Old fill:#3d2f1f,stroke:#e0af68,color:#e6e6e6
Region Purpose Typical Size Collected By
New Space Newly allocated objects ~16 MB total (8 MB active) Scavenge (minor GC)
Old Pointer Space Promoted objects with references Grows as needed Mark-Sweep-Compact (major GC)
Old Data Space Promoted objects without references (raw data) Grows as needed Mark-Sweep-Compact
Large Object Space Objects larger than ~1 MB One page per object Mark-Sweep
Code Space JIT-compiled machine code Small Mark-Sweep

How Garbage Collection Actually Works

Minor GC (Scavenge) - runs on New Space. Uses Cheney’s algorithm with two semi-spaces (From and To). On collection, live objects are copied from From to To. Anything not copied is garbage. Fast (typically <5ms) but only handles young objects. Objects that survive two minor GCs are promoted to Old Space.

Major GC (Mark-Sweep-Compact) - runs on Old Space. Three phases:

  1. Mark - Walk the object graph starting from roots (globals, stack, registers). Mark every reachable object.
  2. Sweep - Reclaim memory occupied by unmarked objects.
  3. Compact - Defragment by moving live objects together. Reduces fragmentation but expensive.
sequenceDiagram
    participant JS as JavaScript Code
    participant NS as New Space
    participant OS as Old Space
    participant GC as Garbage Collector

    JS->>NS: Allocate user, request, response
    Note over NS: 8MB threshold hit
    GC->>NS: Minor GC (Scavenge)
    GC->>NS: Copy survivors From → To
    Note over NS: request, response dropped
    GC->>NS: Survived 2 GCs → promote
    GC->>OS: Move connection pool
    Note over OS: Old Space grows
    Note over OS: Old Space threshold reached
    GC->>OS: Major GC (Mark-Sweep-Compact)
    GC->>JS: Pause! 30-200ms

Modern V8 augments this with incremental marking (mark in small chunks between JS execution), concurrent marking (mark on a background thread), and lazy sweeping to keep pause times low. But major GCs can still cause noticeable pauses, especially on large heaps - which is exactly why memory leaks hurt latency before they crash anything.

Reading process.memoryUsage()

Every diagnostic conversation starts here:

console.log(process.memoryUsage());
// {
//   rss: 30539776,        // Resident Set Size - total OS-allocated memory
//   heapTotal: 6136000,   // Total V8 heap committed
//   heapUsed: 2418504,    // Heap actually in use
//   external: 827104,     // C++ objects bound to JS (Buffers, etc.)
//   arrayBuffers: 9920    // ArrayBuffer + SharedArrayBuffer (subset of external)
// }

Understanding each field is the difference between chasing a phantom and finding the actual leak:

  • rss - The total memory the OS has given the process. Includes the heap, stack, code, and external allocations. This is what the kernel sees, what top reports, and what the OOM killer watches.
  • heapTotal - How much heap V8 has reserved from the OS. Grows in chunks, rarely shrinks.
  • heapUsed - How much of that reserved heap is currently in use. This is the number to watch for JS leaks.
  • external - Memory allocated by V8 for objects bound to JavaScript but stored outside the heap. The biggest contributor is usually Buffer.
  • arrayBuffers - Subset of external covering ArrayBuffer and SharedArrayBuffer.

If rss grows but heapUsed is flat, you have a leak in a native addon or an unbounded Buffer pool - not a JavaScript leak. That distinction saves hours of misdirected debugging.

A useful production logger:

const formatMB = bytes => (bytes / 1024 / 1024).toFixed(1) + ' MB';

setInterval(() => {
  const m = process.memoryUsage();
  console.log({
    rss: formatMB(m.rss),
    heapUsed: formatMB(m.heapUsed),
    heapTotal: formatMB(m.heapTotal),
    external: formatMB(m.external)
  });
}, 30_000);

How Memory Leaks Actually Happen

A “leak” in a garbage-collected language is not a forgotten free(). It is an unreachable-but-still-referenced object - something you no longer need, but the GC cannot prove that because a live reference still points to it. Identifying that lingering reference is the entire game. If you want a refresher on how objects are laid out in V8 and what “reachable” really means, see Object memory management in JavaScript.

The four most common culprits in Node.js, in order of how often I have actually seen them in production:

1. Unbounded Caches and Module-Scoped State

Anything declared at module scope lives until the process dies. A cache that only adds and never evicts is the textbook leak.

// Leak: cache grows forever, one entry per request
const cache = {};

function memoize(key, value) {
  cache[key] = value;
}

Fix it with a bounded, eviction-aware cache. lru-cache is the standard:

const { LRUCache } = require('lru-cache');

const cache = new LRUCache({
  max: 1000,           // hard cap on entries
  ttl: 1000 * 60 * 5,  // 5 min auto-expiry
  updateAgeOnGet: true
});

The lesson generalizes: any long-lived collection - Map, Set, Array, or plain object - needs a deliberate eviction policy if it is fed from request-handling code.

2. Event Listeners Without Cleanup

EventEmitter is reference-strong: a registered listener keeps the closure alive, and the closure keeps its captured variables alive. Forgetting to remove a listener on a long-lived emitter is one of the easiest ways to leak.

// Leak: every request adds a listener to the shared bus, never removed
const bus = require('./eventBus');

app.get('/feed', (req, res) => {
  bus.on('update', payload => res.write(payload));  // never removed
});
// Fix: scope the subscription to the request lifecycle
app.get('/feed', (req, res) => {
  const onUpdate = payload => res.write(payload);
  bus.on('update', onUpdate);

  req.on('close', () => bus.off('update', onUpdate));
});

Node will warn you with MaxListenersExceededWarning once you cross 10 listeners on a single emitter - treat that warning as a smoke alarm, not noise.

3. Timers and Promises That Outlive Their Owner

setInterval registered inside a request handler with no clearInterval survives the request and keeps everything its callback references alive. The same applies to setTimeout chains, setImmediate loops, and unresolved Promises that capture large scopes. If the distinction between these scheduling primitives is fuzzy, the guide to Node.js timers — setTimeout, setImmediate & nextTick is worth reading first. For async work that needs to be cancelled cleanly (HTTP requests, fetches, long-running tasks), AbortController and AbortSignal is the idiomatic way to wire teardown into the request lifecycle.

// Leak: interval survives the connection
function startTelemetry(socket) {
  setInterval(() => socket.send(metrics()), 1000);
}

// Fix: tie the interval to the socket lifetime
function startTelemetry(socket) {
  const id = setInterval(() => socket.send(metrics()), 1000);
  socket.on('close', () => clearInterval(id));
}

4. Closures Capturing More Than You Realize

Closures capture the entire enclosing scope, not just the variables you reference. A small returned function can pin a multi-megabyte object alive simply because that object happened to be in scope.

function buildHandler() {
  const largeReferenceData = loadBigBlob();  // 50 MB
  const cheapId = largeReferenceData.id;

  // This closure captures the whole scope - including largeReferenceData
  return function handler() {
    return cheapId;
  };
}

Fix by narrowing the scope:

function buildHandler() {
  const cheapId = (() => loadBigBlob().id)();  // big blob falls out of scope
  return function handler() {
    return cheapId;
  };
}

Bonus: Accidental Globals

In non-strict mode, an assignment without let/const/var creates a property on the global object - which is never garbage collected.

function processBatch(items) {
  results = items.map(transform);  // missing 'const' → global
}

Always use 'use strict' (or ES modules, which are strict by default). The error you get from a stray assignment is far cheaper than a slow leak.

Diagnosing Leaks: Heap Snapshots

When heapUsed grows unbounded, snapshots are your most powerful tool. A heap snapshot is a complete graph of every object on the V8 heap at one moment - sizes, classes, and the references between them.

Capturing a Snapshot

Programmatically (works in production):

const v8 = require('v8');
const path = require('path');

process.on('SIGUSR2', () => {
  const file = path.join('/tmp', `heap-${Date.now()}.heapsnapshot`);
  v8.writeHeapSnapshot(file);
  console.log('Heap snapshot written:', file);
});

Now kill -SIGUSR2 <pid> from a shell dumps a snapshot without restarting the process. Trigger one early, wait, trigger another - comparing the two is what reveals the leak.

Or via inspector (development):

node --inspect server.js

Open chrome://inspect in Chrome, attach to the Node target, open the Memory panel, and take snapshots manually.

Reading a Snapshot

Load the .heapsnapshot file into Chrome DevTools → Memory tab. Three views matter:

flowchart LR
    A[Heap Snapshot] --> B[Summary View<br/>Group by constructor]
    A --> C[Comparison View<br/>Diff two snapshots]
    A --> D[Containment View<br/>Walk reference graph]

    B --> B1[Shallow Size: object itself]
    B --> B2[Retained Size: what GC<br/>would reclaim if removed]

    C --> C1[# New: objects created]
    C --> C2[# Deleted: objects collected]
    C --> C3[Size Delta: net growth]

    D --> D1[Find retainer chain<br/>back to GC roots]

The single most useful workflow:

  1. Take Snapshot 1 after warm-up.
  2. Drive the suspected leak path for a few minutes (real or scripted traffic).
  3. Take Snapshot 2.
  4. Switch to Comparison view, sort by # Delta descending.
  5. Look at the constructor with the largest growing count. Expand one instance. Look at its Retainers panel - that retainer chain, walked back to a GC root, is your leak.

Shallow size is the object itself; retained size is everything that would be freed if the object disappeared. When chasing leaks, retained size is the more interesting number - it tells you which references are expensive.

Production-Grade Tooling

Clinic.js

The friendliest entry point for performance work:

npm install -g clinic

clinic doctor -- node server.js       # quick overall diagnosis
clinic heapprofiler -- node server.js  # heap allocation flame graph
clinic flame -- node server.js         # CPU flame graph

clinic doctor is particularly good - it runs your app, applies load, then tells you whether you have an event-loop, GC, I/O, or CPU problem in plain English.

0x

A flame-graph profiler that’s helpful for finding hot allocation paths:

npx 0x -- node server.js

node –inspect with Allocation Sampling

For continuous allocation tracking (Chrome DevTools → Memory → Allocation sampling), this records where allocations happen, with stack traces, at low overhead. Excellent for figuring out which line of code is producing the millions of short-lived objects you saw in a snapshot.

prom-client + Grafana

Export memory metrics for long-term monitoring:

const client = require('prom-client');

// Collects process_resident_memory_bytes, nodejs_heap_size_used_bytes,
// nodejs_gc_duration_seconds, and friends automatically.
client.collectDefaultMetrics();

app.get('/metrics', async (_req, res) => {
  res.set('Content-Type', client.register.contentType);
  res.end(await client.register.metrics());
});

Once you have nodejs_heap_size_used_bytes flowing into Grafana, plot it as a line per pod across the last 7 days. Leaks become obvious - you’ll see a sawtooth on healthy pods and a relentless climb on leaky ones. For a broader view of what else is worth instrumenting alongside memory — event-loop lag, GC duration, request latency — see Monitoring tips for Node.js applications.

Optimization Strategies

Detection finds existing leaks. These patterns prevent them.

Stream, Don’t Slurp

The fastest way to OOM a Node process is to read a large file or HTTP body into memory:

// Bad: 2GB file → 2GB RSS spike
const data = fs.readFileSync('huge.json');
const parsed = JSON.parse(data);
// Good: constant memory regardless of file size
const { createReadStream } = require('fs');
const { pipeline } = require('stream/promises');
const StreamValues = require('stream-json/streamers/StreamValues');

await pipeline(
  createReadStream('huge.json'),
  StreamValues.withParser(),
  async function* (source) {
    for await (const { value } of source) {
      await processRecord(value);
    }
  }
);

The same principle applies to HTTP responses (pipe directly to a destination), database cursors (use .cursor() not .toArray()), and CSV processing (use a streaming parser). For a deeper introduction to Node streams, see Unlock the Power of Streams in Node.js and Understanding backpressure and stream optimization — backpressure in particular is what keeps a producer from out-allocating a slow consumer and blowing past your heap budget.

WeakMap / WeakRef for Caches Keyed by Objects

If your cache is keyed by an object reference, WeakMap lets the GC collect the cache entry when the key has no other references - no manual eviction needed:

const memo = new WeakMap();

function compute(node) {
  if (memo.has(node)) return memo.get(node);
  const result = expensive(node);
  memo.set(node, result);
  return result;
}

When node falls out of scope elsewhere, its cache entry vanishes automatically. The catch: keys must be objects, not primitives. WeakRef is the related primitive for holding a single object weakly; see JavaScript WeakRef: the feature you probably shouldn’t use for when it actually earns its place over WeakMap.

Object Pools (Sparingly)

For hot paths that allocate the same shape of object thousands of times per second, a pool can reduce GC pressure:

class BufferPool {
  constructor(size = 16, bufferSize = 4096) {
    this.bufferSize = bufferSize;
    this.pool = Array.from({ length: size }, () => Buffer.allocUnsafe(bufferSize));
  }

  acquire() {
    return this.pool.pop() ?? Buffer.allocUnsafe(this.bufferSize);
  }

  release(buf) {
    if (this.pool.length < 32) this.pool.push(buf);
  }
}

A word of caution: pools add complexity and can themselves leak if release() is forgotten. Profile first; only reach for pooling when allocation cost is demonstrably the bottleneck.

Tune --max-old-space-size Deliberately

By default, V8 caps the old generation at roughly 4 GB on 64-bit systems. If your process legitimately needs more - say, an in-memory cache server - raise it:

node --max-old-space-size=8192 server.js  # 8 GB cap

But raising the limit to “fix” a leak just delays the crash. Profile first. If you actually need more aggregate headroom rather than a fatter single process, horizontal scaling via clustering and IPC in Node.js is usually the better lever — each worker gets its own heap, and a leaky worker can be recycled without taking the fleet down.

Clean Shutdown

A clean shutdown isn’t strictly memory-related, but it surfaces leaks: if your process won’t exit on SIGTERM, something is keeping the event loop alive - a stray interval, an open socket, an unresolved promise. For a production-grade pattern that wires this into Kubernetes-style lifecycle signals, see Health checks and graceful shutdown of an Express.js app using Lightship. Implement explicit teardown:

async function shutdown() {
  await server.close();
  await dbPool.end();
  clearInterval(metricsTimer);
}

process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);

If node still doesn’t exit cleanly after teardown, run with node --trace-exit to see what’s holding it open.

A Practical Debugging Checklist

When a Node service shows memory growth, work through this list in order. Most leaks are caught in the first three steps.

  1. Watch heapUsed vs rss. Heap-only growth → JS leak. RSS-only growth → native/Buffer leak.
  2. Take two heap snapshots an hour apart under load. Comparison view, sort by delta. Largest constructor is your starting point.
  3. Expand a leaking instance, follow the Retainers panel back to a GC root. The reference holding the object alive is almost always the bug.
  4. Audit module-scoped state - caches, Maps, arrays - for unbounded growth.
  5. Audit event listeners on long-lived emitters. emitter.listenerCount(event) is your friend.
  6. Audit timers and promises for missing cleanup tied to request lifecycle.
  7. Check external memory - large Buffer pools, native addons.
  8. Confirm the fix by reproducing the leak in a test environment with --expose-gc and forced global.gc() calls between operations. A clean fix shows flat heap usage after GC.

Summary

  • Node.js memory is dominated by V8’s generational heap. Young objects die fast in New Space; survivors are promoted to Old Space and collected less often but more expensively.
  • The heapUsed field of process.memoryUsage() is the primary signal for JavaScript leaks; rss minus heapUsed points at native or Buffer issues.
  • Leaks are not forgotten free() calls - they are unreachable objects still pinned by a live reference. Heap snapshot diffing finds that reference.
  • The biggest production leak sources, in order: unbounded caches, dangling event listeners, unowned timers/promises, and over-capturing closures.
  • Production needs continuous memory metrics (prom-client + Grafana). On-demand diagnostics need a SIGUSR2-triggered heap snapshot and Chrome DevTools.
  • Optimization wins come from streaming over slurping, WeakMap for object-keyed caches, and disciplined cleanup on shutdown. Reach for pooling and --max-old-space-size only after profiling.

A Node service that runs fast for an hour and OOMs in twelve is not “mostly working” - it is a leak with a long fuse. The tools above turn that fuse into a debuggable graph.