Skip to content
Node.js nd performance 5 min read

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-size below 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.

FlagPurpose
--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-gcExposes global.gc() for manual triggering (testing only)
--trace-gcLogs 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 sourceFix
Unbounded Map/array cachesAdd a size cap / TTL, or use an LRU
Event listeners added per requestRemove with off(), or use { once: true }
Closures captured by long-lived timersNull out references; clear with clearInterval
Module-level accumulationAvoid 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
FieldMeaning
rssTotal resident memory of the process (heap + native + stack)
heapTotalMemory V8 has reserved for the JS heap
heapUsedLive JS objects currently in the heap
externalMemory used by C++ objects bound to JS (e.g. Buffer)
arrayBuffersSubset 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-size explicitly to roughly 75-80% of the container memory limit; never leave it implicit in production.
  • Remember that rss includes native memory and Buffers — 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/WeakRef for object-keyed metadata.
  • Always remove event listeners and clear timers tied to short-lived scopes to prevent slow leaks.
  • Track heapUsed over time in production and alert on a rising baseline rather than absolute values.
  • Use --trace-gc and heap snapshots to diagnose pauses and leaks instead of guessing.
Last updated June 14, 2026
Was this helpful?