NodeJSMemory Management

Memory Management

Node.js runs on the V8 JavaScript engine, which manages memory automatically through a generational garbage collector. Understanding how V8 organizes memory helps you write code that puts less pressure on the GC, diagnose memory leaks, and avoid the class of bugs — event listener accumulation, closure captures, forgotten caches — that cause long-running Node processes to bloat and eventually crash. This page covers V8's heap spaces, the stack vs. heap distinction, how Buffer memory works outside the V8 heap, and how to measure and diagnose memory usage.

V8 heap spaces

Text
V8 Memory Layout
  ├─ Stack             Function call frames, local primitives (number, boolean, pointer)
  │                   Fast allocation/deallocation; limited size (~1MB default)
  │
  └─ Heap
       ├─ New Space (Young Generation)  ~1–8 MB
       │    Objects allocated here first; short-lived objects die here (minor GC)
       │    Two semi-spaces (from/to): GC copies survivors, frees the other half
       │
       ├─ Old Space (Old Generation)    most of the heap
       │    Objects that survive 2 minor GCs are promoted here
       │    Major GC (mark-sweep-compact) runs here — more expensive
       │
       ├─ Code Space                    Compiled JIT code
       ├─ Map Space                     Hidden class descriptors (object shapes)
       └─ Large Object Space            Objects > ~500KB; never moved by GC

  Outside V8 heap (not counted in heap stats):
       Buffer / TypedArray              Node's Buffer allocates via malloc, tracked separately
Most objects start in New Space and are collected cheaply — objects that live long enough get promoted to Old Space where GC is more expensive
V8's generational hypothesis is that **most objects die young**: the result of a JSON.parse, a temporary array in a request handler, an intermediate string — these live for milliseconds. The **New Space** minor GC (Scavenge) is designed to collect these cheaply and frequently: it runs in microseconds and only scans New Space. Objects that survive two minor GCs are **promoted** to Old Space, where the major GC (mark-sweep-compact) handles them. Major GC is far more expensive — it scans the entire old generation and can pause the process for tens of milliseconds (though V8's incremental and concurrent GC reduces pauses significantly). The practical implication: **minimize long-lived objects**. Every object that stays alive gets promoted and increases major GC pressure.
Checking memory usage at runtime

TS
// process.memoryUsage() — snapshot of current memory state:
const mem = process.memoryUsage()
console.log({
  heapTotal:    Math.round(mem.heapTotal    / 1024 / 1024) + ' MB', // V8 heap capacity
  heapUsed:     Math.round(mem.heapUsed     / 1024 / 1024) + ' MB', // live objects
  rss:          Math.round(mem.rss          / 1024 / 1024) + ' MB', // total process memory (heap + stack + buffers)
  external:     Math.round(mem.external     / 1024 / 1024) + ' MB', // C++ objects (Buffers)
  arrayBuffers: Math.round(mem.arrayBuffers / 1024 / 1024) + ' MB', // SharedArrayBuffer + ArrayBuffer
})
{
  heapTotal:    48 MB,
  heapUsed:     32 MB,
  rss:          75 MB,
  external:     2 MB,
  arrayBuffers: 0 MB
}
heapUsed is live objects; rss is total process memory including Buffers and native code — track heapUsed over time to detect leaks
`heapUsed` is the best single metric for detecting JavaScript-side memory leaks — if it grows steadily over time with no corresponding load increase, something is holding object references. `rss` (Resident Set Size) includes the heap plus the stack, native code, and Buffer memory — it grows with `heapTotal` but can also grow from large `Buffer` allocations that don't appear in `heapUsed`. `external` specifically tracks memory owned by C++ objects bound to JavaScript objects (mainly `Buffer`). A common source of confusion: `heapTotal` can be much larger than `heapUsed` — V8 pre-allocates capacity and releases it gradually, not immediately after GC. Rising `heapTotal` with flat `heapUsed` is normal; rising `heapUsed` across requests or time is a leak.
Buffer memory — outside the V8 heap

TS
// Node.js Buffer allocates memory via malloc (C++ layer), NOT in the V8 heap.
// This memory is tracked in process.memoryUsage().external, not heapUsed.

// Buffer.alloc — zero-fills for security (slower, use for sensitive data):
const secure = Buffer.alloc(1024)

// Buffer.allocUnsafe — skips zero-fill, may contain stale memory (faster):
const fast = Buffer.allocUnsafe(1024)

// Buffer.from(string) — copies string data into a new Buffer:
const buf = Buffer.from('hello world', 'utf8')

// ⚠️ Buffer.allocUnsafe content is UNINITIALIZED — always write before reading:
const b = Buffer.allocUnsafe(16)
b.fill(0)    // or write your data before exposing it to user code
// Never send Buffer.allocUnsafe content to a client without writing to it first.
Buffer.allocUnsafe may contain leftover memory from other objects — always fill or overwrite before using; never send uninitialized Buffer data to clients
`Buffer.allocUnsafe` skips the zero-fill step for performance, but the memory it returns may contain **arbitrary data from previous allocations** — file contents, other users' request data, or anything that happened to be in that memory region. This is a serious security risk if the uninitialized buffer is sent in a response or logged. Use `Buffer.allocUnsafe` only when you immediately write your own data to it (e.g., using it as a write target for a stream). For buffers that might be partially unwritten, use `Buffer.alloc` which zero-fills. The performance difference only matters at high throughput with large buffers — profile before optimizing away the safety.
Common memory leak patterns

Pattern

Why it leaks

Fix

Accumulated event listeners

emitter.on() without emitter.off() — each call adds another listener; object can't be GC'd

Call .off() or use .once(); check emitter.listenerCount() in tests

Global/module-level caches

Map/array appended to on every request but never evicted

Use LRU cache with size/TTL limit (e.g. lru-cache)

Closures capturing large objects

Inner function holds reference to outer scope variable — prevents GC even after outer function returns

Null out large variables when done; avoid capturing entire req objects in long-lived closures

Forgotten timers/intervals

setInterval callback captures variables in closure; interval never cleared

Store the ID and call clearInterval(id) on shutdown or cleanup

Unended streams

Readable/Writable stream not consumed or destroyed — buffers back-pressure in memory

Always pipe to a destination or call stream.destroy() on error/cleanup

Diagnosing memory leaks with heap snapshots

TS
import v8 from 'node:v8'
import fs from 'node:fs'

// Write a heap snapshot to disk — open in Chrome DevTools (Memory tab → Load profile):
function writeHeapSnapshot(label: string) {
  const filename = `heap-${label}-${Date.now()}.heapsnapshot`
  const snapshot = v8.writeHeapSnapshot(filename)
  console.log('Heap snapshot written:', snapshot)
}

// Typical workflow:
// 1. writeHeapSnapshot('baseline')  — before load
// 2. Run load / exercise the leak path
// 3. writeHeapSnapshot('after')     — after load
// 4. Compare in Chrome DevTools: filter by "Objects allocated between snapshots"
//    to see what grew — the retainer chain shows what's holding the reference
Setting the heap size limit

Bash
# Default heap limit is ~1.5 GB on 64-bit (V8 sets this based on available RAM)
# Increase for memory-intensive workloads:
node --max-old-space-size=4096 server.js   # 4 GB old space

# Or via NODE_OPTIONS environment variable:
NODE_OPTIONS="--max-old-space-size=4096" node server.js

# ⚠️ This is a ceiling, not a target — the process still uses only what it needs.
# If your process regularly hits the limit, you have a leak or need to redesign.
Raising --max-old-space-size hides leaks rather than fixing them — diagnose first; increase only when you've confirmed the workload genuinely requires more memory
The instinct when a Node process OOM-crashes is to add `--max-old-space-size`. This is sometimes the right call (the workload legitimately needs more heap), but it frequently masks a leak. A leaking process will OOM-crash at the higher limit too — it just takes longer. Before raising the limit, take heap snapshots before and after load, compare the retained object counts, and find the root reference that's keeping objects alive. The fix is almost always one of the patterns in the table above: an unbounded cache, uncleaned event listener, or closure capturing more than it should.
Next
How V8's garbage collector reclaims dead objects: [Garbage Collection in V8](/nodejs/garbage-collection).