Memory Management & Garbage Collection
Node.js runs on V8, the same JavaScript engine as Chrome, and V8 manages memory for you with an automatic garbage collector. You never call free(), but that does not mean memory is free of concern: V8 imposes a hard limit on the heap, GC pauses can show up as latency spikes, and a single retained reference can slowly leak your process to death. Understanding how V8 allocates, collects, and bounds memory lets you size your processes correctly, keep allocations cheap, and catch leaks before they page you at 3 a.m.
How V8 organizes the heap
V8 splits the heap into a young generation (the “new space”) and an old generation (the “old space”). Almost every object is born in young space, which is small (a few megabytes) and collected very frequently. The insight behind this design — the generational hypothesis — is that most objects die young: request-scoped buffers, temporary strings, and intermediate arrays are created and discarded within a single tick.
- Scavenge (minor GC): runs on young space using a fast copying collector. Surviving objects are copied to a second semi-space; objects that survive twice are promoted to old space. Scavenges are quick (sub-millisecond to a few ms) and happen constantly.
- Mark-sweep-compact (major GC): runs on old space. It marks reachable objects, sweeps the dead ones, and occasionally compacts to reduce fragmentation. Major GCs are rarer but more expensive, and are the usual cause of visible GC pauses.
Modern V8 does much of this work concurrently and incrementally on background threads, so full stop-the-world pauses are short — but they are never zero.
Heap limits and --max-old-space-size
V8 caps the old-space size. Historically the default was around 2 GB on 64-bit systems; on recent Node versions the default scales with available system memory, but you should never rely on the implicit value in production. When the old space approaches its limit, V8 runs increasingly aggressive GCs, and once it cannot reclaim enough, the process crashes with a fatal error.
<--- Last few GCs --->
[12345:0x...] FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memory
Set the limit explicitly to match the memory you have actually provisioned (for example, a container with a 1 GB limit):
node --max-old-space-size=896 server.js
# or via the environment, useful when you don't control the launch command
NODE_OPTIONS="--max-old-space-size=896" node server.js
Always set
--max-old-space-sizebelow your container/cgroup memory limit, not equal to it. V8’s heap is only part of process RSS — buffers, native addons, and the stack live outside the heap. A common rule of thumb is to give V8 roughly 75-80% of the container limit.
| Flag | Purpose |
|---|---|
--max-old-space-size=<MB> | Cap the old-generation heap |
--max-semi-space-size=<MB> | Size young space; larger values mean fewer scavenges, more memory |
--expose-gc | Exposes global.gc() for manual triggering (testing only) |
--trace-gc | Logs every GC event to stderr |
Reducing allocations
The cheapest garbage is the garbage you never create. Lowering allocation pressure means fewer scavenges and far less promotion into old space.
// Allocates a fresh array + objects on every call — churns young space
function parseLines(text) {
return text.split("\n").map((line) => ({ value: line.trim() }));
}
// Reuse buffers and avoid intermediate arrays in hot loops
const decoder = new TextDecoder();
function countLines(buf) {
let count = 0;
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0x0a) count++; // newline
}
return count;
}
Practical levers: stream large payloads instead of buffering them whole, reuse Buffer instances or use a pool, avoid building giant intermediate arrays when an iterator or a single pass will do, and be wary of closures that capture large objects unnecessarily.
Avoiding leaks
A leak in a GC’d language is not unfreed memory — it is memory that is still reachable but will never be used again. The most common culprits keep growing without bound:
// LEAK: every request adds an entry that is never removed
const sessions = new Map();
app.use((req, res, next) => {
sessions.set(req.id, { user: req.user, at: Date.now() });
next();
});
Frequent sources of leaks and their fixes:
| Leak source | Fix |
|---|---|
Unbounded Map/array caches | Add a size cap / TTL, or use an LRU |
| Event listeners added per request | Remove with off(), or use { once: true } |
| Closures captured by long-lived timers | Null out references; clear with clearInterval |
| Module-level accumulation | Avoid global mutable collections |
When you only want to associate data with an object while it lives, reach for WeakMap or WeakRef — entries are collected automatically once the key object is otherwise unreachable.
const metadata = new WeakMap();
function tag(obj, info) {
metadata.set(obj, info); // no leak: drops when `obj` is GC'd
}
Monitoring with process.memoryUsage
Node exposes live memory stats. Sample them on an interval and ship them to your metrics backend; a steadily rising heapUsed baseline across deploys is the signature of a leak.
function logMemory() {
const m = process.memoryUsage();
const mb = (n) => (n / 1024 / 1024).toFixed(1);
console.log(
`rss=${mb(m.rss)}MB heapTotal=${mb(m.heapTotal)}MB ` +
`heapUsed=${mb(m.heapUsed)}MB external=${mb(m.external)}MB`,
);
}
setInterval(logMemory, 5000).unref();
Output:
rss=78.4MB heapTotal=41.2MB heapUsed=29.8MB external=3.1MB
rss=81.0MB heapTotal=44.7MB heapUsed=31.2MB external=3.1MB
| Field | Meaning |
|---|---|
rss | Total resident memory of the process (heap + native + stack) |
heapTotal | Memory V8 has reserved for the JS heap |
heapUsed | Live JS objects currently in the heap |
external | Memory used by C++ objects bound to JS (e.g. Buffer) |
arrayBuffers | Subset of external for ArrayBuffer/Buffer |
For deeper investigation, capture a heap snapshot with import { writeHeapSnapshot } from "node:v8" and load the .heapsnapshot file into Chrome DevTools, where the comparison view reveals exactly which retained objects are growing.
Best Practices
- Set
--max-old-space-sizeexplicitly to roughly 75-80% of the container memory limit; never leave it implicit in production. - Remember that
rssincludes native memory andBuffers — sizing only for the JS heap will get your process OOM-killed. - Reduce allocation churn in hot paths: stream instead of buffering, reuse buffers, and avoid large intermediate arrays.
- Bound every cache with a size cap or TTL, and prefer
WeakMap/WeakReffor object-keyed metadata. - Always remove event listeners and clear timers tied to short-lived scopes to prevent slow leaks.
- Track
heapUsedover time in production and alert on a rising baseline rather than absolute values. - Use
--trace-gcand heap snapshots to diagnose pauses and leaks instead of guessing.