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.

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 exceededfrom 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
Bufferdata and memory held by native addons. Reported underexternalandarrayBuffersinprocess.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:
- Mark - Walk the object graph starting from roots (globals, stack, registers). Mark every reachable object.
- Sweep - Reclaim memory occupied by unmarked objects.
- 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, whattopreports, 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 usuallyBuffer.arrayBuffers- Subset ofexternalcoveringArrayBufferandSharedArrayBuffer.
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:
- Take Snapshot 1 after warm-up.
- Drive the suspected leak path for a few minutes (real or scripted traffic).
- Take Snapshot 2.
- Switch to Comparison view, sort by
# Deltadescending. - 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.
- Watch
heapUsedvsrss. Heap-only growth → JS leak. RSS-only growth → native/Buffer leak. - Take two heap snapshots an hour apart under load. Comparison view, sort by delta. Largest constructor is your starting point.
- Expand a leaking instance, follow the Retainers panel back to a GC root. The reference holding the object alive is almost always the bug.
- Audit module-scoped state - caches,
Maps, arrays - for unbounded growth. - Audit event listeners on long-lived emitters.
emitter.listenerCount(event)is your friend. - Audit timers and promises for missing cleanup tied to request lifecycle.
- Check
externalmemory - largeBufferpools, native addons. - Confirm the fix by reproducing the leak in a test environment with
--expose-gcand forcedglobal.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
heapUsedfield ofprocess.memoryUsage()is the primary signal for JavaScript leaks;rssminusheapUsedpoints at native orBufferissues. - 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 aSIGUSR2-triggered heap snapshot and Chrome DevTools. - Optimization wins come from streaming over slurping,
WeakMapfor object-keyed caches, and disciplined cleanup on shutdown. Reach for pooling and--max-old-space-sizeonly 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.