NodeJSTuning the Garbage Collector

Tuning the Garbage Collector

V8 exposes several command-line flags that adjust garbage collector behaviour. Most production Node applications run fine with defaults — but if you've profiled your app and found that GC pauses are causing latency spikes, or that heap growth is contributing to OOM crashes, the flags on this page give you levers to pull. The rule is: measure first, tune second. Flags that help one workload can hurt another. This page covers the flags that matter most, what each controls, and the workload characteristics that make tuning worthwhile.

Most applications do not need GC tuning
Profile before tuning — GC flags rarely fix problems that are better solved by fixing memory leaks or reducing long-lived object accumulation
V8's default GC heuristics are well-tuned for a wide range of workloads. Before reaching for flags, make sure you've: (1) confirmed with `--trace-gc` or `perf_hooks` that GC is actually the cause of your latency or memory problem; (2) checked for memory leaks (growing `heapUsed` over time independent of load); (3) confirmed you're running Node 18+ which has the most current incremental/concurrent GC. If a major GC pause is causing a latency spike, the most effective fix is usually **reducing Old Space size** by evicting caches sooner — not increasing it. Flags are a last resort for applications with unusual allocation patterns.
Key GC flags

Flag

Default

Effect

--max-old-space-size=<MB>

~1400–1500 MB (64-bit)

Hard ceiling on Old Space heap size; process exits with OOM if exceeded

--max-semi-space-size=<MB>

8 MB

New Space semi-space size; larger = fewer minor GCs but more memory per promotion survivor

--initial-heap-size=<MB>

V8 chooses

Pre-allocate heap at startup; avoids growth overhead for known-large workloads

--optimize-for-size

off

Prefer smaller memory footprint over throughput; trades some speed for lower memory

--expose-gc

off (dev only)

Exposes global.gc() for manual GC triggering in tests — never use in production

--trace-gc

off

Logs each GC event with type, heap sizes before/after, and duration

--trace-gc-verbose

off

More detailed GC logging including pause reasons

Adjusting heap size limits

Bash
# Raise Old Space ceiling for memory-intensive batch jobs:
node --max-old-space-size=4096 batch-processor.js

# Set via environment variable (useful in Docker/Kubernetes):
NODE_OPTIONS="--max-old-space-size=2048" node server.js

# Increase New Space to reduce minor GC frequency for allocation-heavy workloads:
node --max-semi-space-size=32 server.js   # 32 MB semi-space (from default 8 MB)
# Doubles New Space total (two semi-spaces = 64 MB instead of 16 MB)
# Fewer minor GCs, but more memory; check if survivors also increase
Raising --max-old-space-size beyond available RAM causes the OS to swap, which is far worse than an OOM crash — always leave headroom for OS and other processes
If you set `--max-old-space-size` to 8 GB on a machine with 8 GB RAM, the process won't crash from heap exhaustion — but when Old Space fills up, the OS will start swapping pages to disk to accommodate the GC's working set. This causes multi-second pauses that are far worse than a clean OOM crash and restart. On a machine with N GB RAM, a single Node process should use at most N*0.75 GB heap to leave room for OS buffers, stack, Buffers, and other processes. In containerized environments, set `--max-old-space-size` to ~75% of the container memory limit and set the container memory limit explicitly.
Tuning New Space size

TS
// Diagnose minor GC frequency first — count Scavenge events per second:
import { PerformanceObserver, constants } from 'node:perf_hooks'

let scavengeCount = 0
const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if ((entry.detail as any).kind === constants.NODE_PERFORMANCE_GC_MINOR) scavengeCount++
  }
})
obs.observe({ entryTypes: ['gc'] })

setInterval(() => {
  console.log(`Minor GC rate: ${scavengeCount}/s`)
  scavengeCount = 0
}, 1000)

// If you see 50+ minor GCs/sec under load:
// → Increase --max-semi-space-size (16, 32, 64 MB) and re-measure
// → Also check if you can reduce allocation rate (reuse objects, use object pools)
Object pooling — reduce allocation pressure

TS
// For hot paths that allocate many identical objects, reuse them:
class BufferPool {
  private pool: Buffer[] = []
  private readonly size: number

  constructor(bufferSize: number, private readonly maxSize = 100) {
    this.size = bufferSize
  }

  acquire(): Buffer {
    return this.pool.pop() ?? Buffer.allocUnsafe(this.size)
  }

  release(buf: Buffer): void {
    if (this.pool.length < this.maxSize) {
      this.pool.push(buf)
    }
    // If pool is full, let the buffer be GC'd
  }
}

const pool = new BufferPool(4096, 50)   // pool of 4KB buffers

async function processRequest() {
  const buf = pool.acquire()
  try {
    // ... use buf
  } finally {
    pool.release(buf)
  }
}
Object pooling reduces GC pressure by reusing allocations — effective for fixed-size buffers and short-lived objects on hot paths, but adds complexity
Every `new` allocates in New Space and eventually triggers a minor GC. On extremely hot paths (thousands of small allocations per second), reducing allocation rate directly reduces GC frequency. Object pools recycle objects instead of allocating new ones, keeping allocation pressure low. The trade-off: pools add code complexity, and if the pool size is too large it keeps objects alive in Old Space unnecessarily. Profile first — if minor GC is running dozens of times per second and your allocation rate is the cause, pooling the most frequently-created objects is a measurable win. For most applications, reducing careless allocations (unnecessary array spreads, repeated string concatenation in loops, per-request module re-requires) is more impactful than formal pools.
Exposing GC for tests

Bash
# Only use --expose-gc in tests to verify cleanup logic:
node --expose-gc test-memory.js

TS
// In a test file — verify that objects are collected when expected:
declare const gc: () => void   // available only with --expose-gc

test('cache does not leak after clear', () => {
  const cache = new WeakRef(buildExpensiveCache())
  // ... use and clear the cache
  gc()   // force a GC cycle
  expect(cache.deref()).toBeUndefined()   // should have been collected
})
Never call gc() in production — it forces a full GC cycle, pausing the event loop; --expose-gc is for test isolation only
`global.gc()` triggers a synchronous, stop-the-world full GC. In a test this is useful for deterministically triggering cleanup. In a production server it would pause request handling for the duration of the GC cycle — potentially tens to hundreds of milliseconds on a large heap. The flag `--expose-gc` should never appear in a production start command. Confirm this in code review: if you see `--expose-gc` outside a test runner configuration, it's a bug.
GC tuning checklist
  • Run with --trace-gc and verify GC is the actual cause of latency before changing any flags.

  • Fix memory leaks first — a leaking heap will OOM at any limit.

  • Set --max-old-space-size to ≤75% of available/container RAM; never exceed physical memory.

  • If minor GC rate is very high (50+/sec), try doubling --max-semi-space-size and re-measure.

  • Use --initial-heap-size for batch jobs that start with known large data to avoid incremental heap growth.

  • Pool allocations on hot paths only after profiling confirms allocation rate is the bottleneck.

  • Never use --expose-gc outside test environments.

Next
Extend Node with native C/C++ performance-critical code: [Native Addons (N-API)](/nodejs/native-addons).