Memory Management and Garbage Collection
Every Node.js process runs inside the V8 JavaScript engine, and V8 is responsible for allocating and freeing the memory your objects, strings, arrays, and closures live in. You never call free() yourself — instead, V8’s garbage collector (GC) periodically scans memory, figures out which objects are no longer reachable from your running code, and reclaims that space automatically. Understanding how this works matters because a Node process that leaks memory will slowly grow until it crashes with an out-of-memory error, and a process that triggers garbage collection too aggressively will stall the event loop and hurt latency. This lesson explains how V8’s heap is organized, how to inspect memory usage, and how to recognize and fix the memory mistakes that show up constantly in real servers.
Overview: How Memory Management Works in Node.js
When your code runs, primitive values and references to objects live on the call stack as each function is invoked. The objects themselves — plain objects, arrays, closures, class instances — live on the heap, a region of memory V8 manages. As your program creates objects, the heap grows; when objects become unreachable (nothing in your running code can get to them anymore), they become eligible for collection.
V8 uses a generational garbage collector, based on the empirical observation that most objects die young (a request handler’s local variables, a temporary array used to format a response) while a smaller set of objects live for the whole process lifetime (a database connection pool, an in-memory cache). V8 splits the heap into regions to exploit this:
- New Space (young generation) — where new objects are allocated. It is small and split into two equal semi-spaces. When one fills up, V8 runs a fast Scavenge (minor GC): it copies every still-reachable object into the other semi-space and discards the rest in one pass. Objects that survive two scavenges are promoted to Old Space.
- Old Space (old generation) — holds long-lived objects. Because this space is much larger, scanning it is expensive, so V8 uses mark-sweep-compact: it marks every object reachable from the "roots" (the stack, global variables, active closures), sweeps away everything unmarked, and periodically compacts memory to reduce fragmentation. This is the major GC, and it is far more expensive than a scavenge.
- Large Object Space — objects too big to fit the normal allocation pages (large arrays, big strings) are allocated here directly and managed separately.
- Code Space and Map Space — hold compiled JIT code and the hidden "shape" metadata V8 uses to optimize object property access. You rarely interact with these directly, but they count toward total memory usage.
There is also memory V8 does not track on its JavaScript heap at all: external memory. Buffer instances and ArrayBuffers backing typed arrays are allocated outside the V8 heap (closer to raw C++ allocation) and are only freed once the JavaScript wrapper object referencing them is garbage collected. This is why a program that allocates lots of Buffers can grow in memory even though the V8 heap itself looks small — check the external and arrayBuffers fields, not just heapUsed.
Modern V8 (as shipped in Node 20/22) performs a lot of this marking work incrementally and concurrently on background threads to minimize how long it pauses your main JavaScript thread, but a full major GC can still pause execution for several milliseconds to tens of milliseconds on a large heap — long enough to matter for a latency-sensitive server.
Syntax: Inspecting Memory
Node exposes memory information through a few core APIs. The most common is process.memoryUsage(), which returns an object describing the current process’s memory footprint in bytes:
const used = process.memoryUsage();
for (const key of Object.keys(used)) {
const mb = Math.round((used[key] / 1024 / 1024) * 100) / 100;
console.log(`${key}: ${mb} MB`);
}
| Field | Meaning |
|---|---|
rss |
Resident Set Size — total memory allocated for the process, including heap, stack, and native code. |
heapTotal |
Total size V8 has currently allocated for the JS heap. |
heapUsed |
Memory actually in use by live JS objects on the heap. |
external |
Memory used by C++ objects bound to JS objects, including most Buffer data. |
arrayBuffers |
Memory allocated for ArrayBuffers and SharedArrayBuffers (a subset of external). |
For deeper inspection, the built-in node:v8 module exposes v8.getHeapStatistics() and v8.getHeapSpaceStatistics(), which break memory down by the generational spaces described above. You can also influence the collector with command-line flags:
| Flag | Purpose |
|---|---|
--max-old-space-size=<MB> |
Caps how large Old Space can grow before V8 works harder to collect or the process runs out of memory. Useful in memory-constrained containers. |
--max-semi-space-size=<MB> |
Sizes the young generation; larger values mean fewer, cheaper scavenges but more memory held by short-lived objects. |
--expose-gc |
Exposes a global gc() function you can call manually — useful for diagnostics and tests, never for production logic. |
--trace-gc |
Prints a line to stderr every time a collection runs, including its duration. |
Examples
Example 1: Reading basic memory usage
The snippet above prints a snapshot of the process’s memory. Run it right after startup and the numbers will be small; run it inside a long-lived server after handling thousands of requests and you can watch heapUsed and rss for signs of unbounded growth.
rss: 38.45 MB
heapTotal: 6.7 MB
heapUsed: 4.42 MB
external: 1.04 MB
arrayBuffers: 0.01 MB
Exact numbers vary between machines and Node versions — what matters is watching how they change over time, not their absolute value.
Example 2: Forcing a collection and comparing heap stats
You can force a garbage collection on demand (only for diagnostics, never in production code paths) by starting Node with --expose-gc:
const v8 = require("node:v8");
function makeGarbage() {
let big = new Array(1_000_000).fill("x");
big = null; // drop the only reference so it becomes eligible for collection
}
console.log("before:", v8.getHeapStatistics().used_heap_size);
makeGarbage();
if (global.gc) {
global.gc();
console.log("after gc:", v8.getHeapStatistics().used_heap_size);
} else {
console.log("run with --expose-gc to force a collection and see the difference");
}
node --expose-gc app.js
Without global.gc available, the script just tells you to add the flag. With it, you’ll see used_heap_size drop after the forced collection because the million-element array had no remaining references and was reclaimed. In real code you never call global.gc() — V8 decides when to collect based on allocation pressure, and forcing it can actually make an application slower.
Example 3: Watching the heap grow across allocations
const usedMb = () => Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
let store = [];
let ticks = 0;
const timer = setInterval(() => {
store.push(new Array(100_000).fill("x"));
ticks++;
console.log(`tick ${ticks}: heapUsed = ${usedMb()} MB`);
if (ticks >= 5) {
clearInterval(timer);
store = null; // drop the reference so the arrays become garbage
}
}, 200);
tick 1: heapUsed = 12.3 MB
tick 2: heapUsed = 20.1 MB
tick 3: heapUsed = 27.8 MB
tick 4: heapUsed = 35.6 MB
tick 5: heapUsed = 43.4 MB
Each tick allocates a new array that immediately gets promoted from New Space to Old Space because store keeps a live reference to it. Once store = null runs, every array becomes unreachable and the next major GC reclaims all of it — but note we had to explicitly clear the interval and drop the reference; neither happens automatically.
Under the Hood: The Order of Operations
When you allocate an object with {}, [], or new, here is what actually happens:
- V8 allocates space for it in New Space (unless it’s large enough to go straight to Large Object Space).
- As allocations fill one semi-space, V8 triggers a Scavenge: it walks from the roots (stack frames, global object, active closures), copies every reachable object into the empty semi-space, and treats everything left behind as garbage. This is fast because most objects don’t survive.
- An object that survives two scavenges is promoted to Old Space, on the assumption it’s going to live a while.
- As Old Space fills, V8 schedules a major GC: incremental marking runs in small steps interleaved with your code (and partly on background threads) to identify all reachable objects, then a sweep phase reclaims unmarked memory, and periodically a compaction pass defragments the heap.
Buffer/ArrayBufferdata allocated outside the JS heap is freed only once its JS wrapper object is collected — so external memory lags behind JS-visible garbage by however long it takes for that wrapper to be swept.
The key implication: garbage collection is not instantaneous and not under your direct control. Setting a variable to null doesn’t free memory immediately — it just makes the object eligible for collection whenever V8 next runs a GC pass.
Common Mistakes
Mistake 1: Unbounded caches and logs
The most common Node memory leak is a module-level array or object that keeps growing and is never trimmed:
const http = require("node:http");
// BAD: this array grows forever and is never cleared
const requestLog = [];
const server = http.createServer((req, res) => {
requestLog.push({ url: req.url, time: Date.now() });
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
});
server.listen(3000, () => {
console.log("listening on http://localhost:3000");
});
Every request permanently grows requestLog. In a long-running server this eventually exhausts memory. Fix it by bounding the size, evicting old entries, or using a proper time-based cache with expiration:
const http = require("node:http");
const MAX_LOG_ENTRIES = 1000;
const requestLog = [];
const server = http.createServer((req, res) => {
requestLog.push({ url: req.url, time: Date.now() });
if (requestLog.length > MAX_LOG_ENTRIES) {
requestLog.shift(); // drop the oldest entry so memory stays bounded
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
});
server.listen(3000, () => {
console.log("listening on http://localhost:3000");
});
Mistake 2: Forgotten event listeners
Registering a new listener every time a function runs, without ever removing it, keeps every closure (and everything it references) alive forever:
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
function attachLogger(requestId) {
// BAD: a new listener is added every call and never removed
emitter.on("response", () => {
console.log(`handled ${requestId}`);
});
}
for (let i = 0; i < 20; i++) {
attachLogger(i);
}
console.log(emitter.listenerCount("response"));
20
Past the default limit of 10 listeners per event, Node also prints a MaxListenersExceededWarning to stderr — that warning exists specifically to catch this class of leak. Fix it by removing the listener once it has done its job, or using once() when a listener should only fire a single time:
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
function attachLogger(requestId) {
const onResponse = () => {
console.log(`handled ${requestId}`);
emitter.removeListener("response", onResponse); // clean up after firing
};
emitter.on("response", onResponse);
}
attachLogger(1);
emitter.emit("response");
console.log(emitter.listenerCount("response"));
handled 1
0
Other leaks worth knowing
A setInterval or setTimeout that is never cleared keeps its callback closure — and everything that closure references — alive for the life of the process, exactly like the timer in Example 3 before it calls clearInterval. And in memory-constrained environments like containers, leaving --max-old-space-size at its default can let V8 grow the heap right up to the container's memory limit before the kernel OOM-kills the process with no useful error message; setting an explicit, slightly lower limit lets V8 fail more gracefully and lets you catch the problem in monitoring first.
Best Practices
- Prefer
WeakMapandWeakReffor caches keyed by objects — entries are collected automatically once the key object is no longer referenced elsewhere, instead of pinning it in memory forever. - Bound every cache, log, and queue with a maximum size or a time-to-live; "it will never get that big" is how leaks happen in production.
- Always pair
addListener/onwith a matchingremoveListener, or useonce()for one-shot handlers. - Always clear timers you no longer need with
clearInterval()/clearTimeout(). - Stream large files and responses instead of buffering them fully in memory with
readFileSyncor by concatenating chunks into one giant string. - Set
--max-old-space-sizeexplicitly in containerized deployments so V8's limit stays comfortably under the container's memory limit. - Use
process.memoryUsage()in health checks or metrics to catch upward trends inrss/heapUsedbefore they become outages. - For serious leak hunts, take heap snapshots with the
--inspectflag and Chrome DevTools, and compare two snapshots taken minutes apart to see which object types are accumulating. - Never call
global.gc()in application logic — it's a diagnostic tool, not a substitute for fixing the underlying reference.
Practice Exercises
- Write a script that pushes a new object into an array every 100ms for 20 iterations, logging
process.memoryUsage().heapUsedeach time. Then set the array reference tonull, run with--expose-gc, and log the heap usage again after callingglobal.gc()to confirm it drops. - Take the leaking
EventEmitterexample from this lesson and rewriteattachLoggerso it can be called 1000 times without ever exceeding 1 listener registered per event. - Using the
node:v8module, write a script that printsheap_size_limitfromv8.getHeapStatistics(), then run the same script with--max-old-space-size=128and confirm the limit reported changes.
Summary
- V8 manages memory with a generational garbage collector: a fast Scavenge for short-lived objects in New Space, and a slower mark-sweep-compact for long-lived objects in Old Space.
BufferandArrayBufferdata lives in external memory outside the JS heap and is only freed when its JS wrapper is collected.process.memoryUsage()andnode:v8'sgetHeapStatistics()are your primary tools for inspecting memory at runtime.- Setting a reference to
nullmakes an object eligible for collection; it does not free memory immediately. - The most common real-world leaks are unbounded caches/logs, forgotten event listeners, and uncleared timers.
- Use
--max-old-space-sizeto cap heap growth, especially in containers, and use--expose-gconly for diagnostics, never in production logic. - Prefer streaming and bounded data structures over buffering everything in memory, and prefer
WeakMap/WeakReffor object-keyed caches.
