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.
Step-by-Step Fix
- Find the enforcing limit. Command:
cat /sys/fs/cgroup/memory.maxinside the container. Expected: a byte value, ormaxwhen unconstrained. - Measure the non-heap footprint at peak. Command: record
rss − heapTotalunder load for an hour. Expected: a stable figure; use its 95th percentile, not its mean. - Subtract and set. Command:
--max-old-space-size=<limitMB − nonHeapMB − marginMB>. Expected: a value near 75% of the limit for an ordinary service. - 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. - 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.
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.
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.
Related
- Memory Limits and Out-of-Heap Errors in Node.js — what V8 does as the ceiling approaches
- Fixing OOMKilled Node.js Containers in Kubernetes — the deployment side of the same arithmetic
- Worker Threads Memory Isolation — budgeting isolates the flag does not reach