Setting max-old-space-size Correctly

--max-old-space-size is the most reached-for flag in Node.js memory work and the most frequently misunderstood, because it governs a smaller region than almost everyone assumes. This page belongs to memory limits and out-of-heap errors in Node.js inside JavaScript Memory Fundamentals & Runtime Mechanics.

Symptom Root Cause Immediate Action
Killed by the kernel with the heap under its ceiling Non-heap memory not counted in the flag Lower the ceiling to leave real headroom
Raising the flag delays the crash but does not fix it Growth is unbounded, not a sizing problem Diagnose retention before changing the flag
Flag appears to have no effect Passed after the script path, or in the wrong variable Verify with v8.getHeapStatistics()
Worker pool exceeds the limit anyway Each worker has its own isolate Set resourceLimits per worker and sum them
Value correct on one deployment, wrong on another Container limit differs by environment Derive the value at start-up

Root Cause: A Ceiling on One Region

The flag sets the maximum size of V8’s old space — the generation where objects that survive two scavenges are promoted. When allocation would push the old space past that ceiling, V8 runs increasingly aggressive collections and, if they do not free enough, aborts with a fatal allocation failure and a stack trace.

Everything else in the process is outside that ceiling. Buffer and ArrayBuffer backing stores are external memory. Native module allocations are outside it. Each worker thread has its own isolate with its own old space and its own ceiling. Thread stacks, the code segment and the runtime’s own baseline of roughly 40 MB are all outside it.

That is why the arithmetic runs backwards from the enforcing limit. Take the container limit, subtract the measured non-heap footprint at peak load, subtract a margin, and what remains is what the old space may have. For a plain JSON service that usually lands near 75% of the limit; for a service that streams large payloads or runs four workers, it can be far lower.

The second failure mode is philosophical rather than arithmetic. Raising the ceiling is the correct fix when the working set genuinely needs more room. It is the wrong fix when growth is unbounded, because a leak fills any ceiling — the only thing a larger number buys is a longer interval between crashes and a slower, larger snapshot when you finally capture one.

What the flag does and does not cover A one gigabyte container limit is divided into an old space of five hundred and seventy-six megabytes governed by the flag, one hundred and sixty of external buffers, one hundred and eighty for two worker isolates, sixty of native and stack memory, and forty of runtime baseline, leaving a small margin. A 1 GB container running a main thread and two workers container limit 1024 MB old space · 576 MB the ONLY region --max-old-space-size governs external · 160 MB Buffers, ArrayBuffers, stream chunks worker isolates · 180 MB two workers, each with its own heap and its own ceiling native + stacks 60 · baseline 40 addons, thread stacks, the runtime itself Setting the flag to 1024 here would be equivalent to setting it to “whatever the kernel allows”, which is the configuration that produces a silent kill instead of a stack trace.

Step-by-Step Fix

  1. Find the enforcing limit. Command: cat /sys/fs/cgroup/memory.max inside the container. Expected: a byte value, or max when unconstrained.
  2. Measure the non-heap footprint at peak. Command: record rss − heapTotal under load for an hour. Expected: a stable figure; use its 95th percentile, not its mean.
  3. Subtract and set. Command: --max-old-space-size=<limitMB − nonHeapMB − marginMB>. Expected: a value near 75% of the limit for an ordinary service.
  4. Confirm the runtime agrees. Command: node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1048576)". Verification: the printed value matches what you set — a common failure is passing the flag after the script path, where it is treated as an argument.
  5. Re-measure after workload changes. Verification: a feature that adds streaming or image decoding moves the non-heap figure, and the ceiling must move with it.

Command & Code Reference

Deriving the value at start-up so one image behaves correctly at any container size.

#!/bin/sh
# Read the limit the kernel is enforcing, whichever cgroup version is present.
if [ -r /sys/fs/cgroup/memory.max ]; then LIMIT=$(cat /sys/fs/cgroup/memory.max)
elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
  LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)
else LIMIT=max; fi

# NON_HEAP_MB comes from measurement, not from a guess. Override it per
# service: a streaming service needs far more than a JSON API.
NON_HEAP_MB=${NON_HEAP_MB:-256}

case "$LIMIT" in
  max|*[!0-9]*) HEAP_MB=1024 ;;
  *) LIMIT_MB=$(( LIMIT / 1048576 ))
     HEAP_MB=$(( LIMIT_MB - NON_HEAP_MB ))
     [ "$HEAP_MB" -lt 128 ] && HEAP_MB=128 ;;   # floor, so it never goes absurd
esac

exec node --max-old-space-size="${HEAP_MB}" server.js

Checking that the flag actually took effect, which is worth doing once per deployment.

const v8 = require('node:v8');
const os = require('node:os');

const limitMb = v8.getHeapStatistics().heap_size_limit / 1048576;
console.log(`v8 heap ceiling: ${limitMb.toFixed(0)} MB`);

// Fail fast on a misconfiguration rather than discovering it during an incident.
const containerMb = Number(process.env.CONTAINER_MEMORY_MB) || os.totalmem() / 1048576;
if (limitMb / containerMb > 0.85) {
  console.warn(
    `heap ceiling is ${(limitMb / containerMb * 100).toFixed(0)}% of the ` +
    `container limit — the kernel will kill this process before V8 aborts`
  );
}

Budgeting worker isolates, which the main process flag does not touch.

const { Worker } = require('node:worker_threads');

const POOL = 4;
const PER_WORKER_MB = 96;      // × 4 = 384 MB, subtracted from the main budget

function spawnWorker(file) {
  return new Worker(file, {
    resourceLimits: {
      maxOldGenerationSizeMb: PER_WORKER_MB,   // this worker's own ceiling
      maxYoungGenerationSizeMb: 16,
    },
  });
}
// Total budget = main old space + POOL × PER_WORKER_MB + external + native.
// Only the first term is governed by --max-old-space-size.
When raising the ceiling helps and when it does not A genuinely large working set plateaus at six hundred and forty megabytes and runs cleanly once the ceiling is above it. A leak rises without limit and crosses every ceiling in turn, so each increase only postpones the abort. Two shapes, and only one of them is a sizing problem 0 640 MB 1.2 GB elapsed → ceiling 384 MB ceiling 768 MB ceiling 1152 MB large working set — plateaus, fits above 768 MB leak — crosses every ceiling in turn Raising the flag is right for the green line and merely postpones the incident for the red one.

Verification & Regression Prevention

Verify by reading the limit from inside the process rather than trusting the command line: v8.getHeapStatistics().heap_size_limit is the value V8 is actually enforcing, and it catches a flag that was passed in the wrong position, swallowed by a process manager, or overridden by NODE_OPTIONS.

For prevention, add the ratio check from the snippet above to start-up so a misconfiguration is a log line on boot rather than a discovery during an outage. And keep the non-heap figure in the deployment configuration next to the limit, with a comment recording when it was measured — the ceiling is only correct relative to a workload, and workloads change.

Non-heap footprint by service profile A plain JSON API uses about a hundred and twenty megabytes of non-heap memory, allowing a ceiling near eighty-five per cent of the limit. A server-rendering service uses two hundred, allowing seventy-five per cent. A streaming or media service uses four hundred, allowing sixty. A worker pool of four uses five hundred and sixty, allowing forty-five. The right percentage depends on what the service does Service profile Non-heap at peak Ceiling, share of a 1 GB limit JSON API, no large payloads ~120 MB ~880 MB (85%) server-side rendering ~200 MB ~768 MB (75%) streaming or media processing ~400 MB ~620 MB (60%) main thread plus 4 workers ~560 MB ~460 MB (45%) Copying the 75% rule onto the bottom row is how a worker pool gets OOMKilled with a healthy-looking heap.

FAQ

Does --max-old-space-size limit the whole process?

No. It limits the V8 old space only. External buffers, native module allocations, worker thread isolates, thread stacks and the runtime baseline all sit outside it, which is why a process can be killed by the kernel while the heap is comfortably under its ceiling.

Do I need the flag at all on modern Node?

Modern Node sizes the default heap from the memory available to the container, which is a reasonable default for a plain service. Set it explicitly when the workload holds significant external memory, when worker threads are involved, or when you want the runtime to abort with a stack trace before the kernel kills the process.

Should each worker thread get its own limit?

Yes. Each worker is a separate isolate with its own heap, and resourceLimits.maxOldGenerationSizeMb sets its ceiling. Multiply the per-worker ceiling by the pool size and add the main thread’s before comparing against the container limit — the flag on the main process does not constrain the workers.