How Node.js Sizes the Default Heap in Containers
The same image runs with a 1 GB heap limit on one cluster and a 4 GB limit on another, containers are OOMKilled without any “heap out of memory” message, or a service with plenty of container memory crashes at 250 MB of heap. All of these come from how Node.js chooses its default heap size. This guide from Memory Limits and Out-of-Heap Errors in Node.js, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains where the default comes from, how container limits enter the calculation, and why setting it explicitly is the reliable choice.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Container OOMKilled, no V8 “heap out of memory” error | Heap limit derived from host memory exceeds the container limit | Set --max-old-space-size below the container limit |
V8 fails cleanly (or GCs harder) before the kernel kills |
| Heap limit differs between environments | Default depends on detected memory and Node version | Log heap_size_limit at startup |
Visible, comparable configuration |
| Heap OOM at a small size in a large container | Default is a fraction of detected memory | Raise --max-old-space-size deliberately |
Uses the memory you provisioned |
| Old Node version ignores cgroup v2 limits | Memory detection predates cgroup v2 support | Upgrade Node or set the limit explicitly | Correct limit on modern clusters |
| RSS exceeds container limit while heap is under its limit | Native memory and buffers not counted in the heap limit | Leave headroom for off-heap memory | Fewer kernel OOM kills |
Root Cause: A Default Derived From “Available Memory”
V8 sets the maximum size of the old generation (and thus most of the heap limit) from the amount of physical memory it is told the system has, using a fixed fraction with lower and upper bounds. Node.js supplies that figure. Modern Node versions ask libuv for the constrained memory — the cgroup memory limit when running in a container — and fall back to total system memory when no constraint is found. The result is a heap limit that is a fraction of what the process is allowed to use, capped at a few gigabytes on large machines. The exact fraction and caps have changed across V8 and Node versions, which is why the same image can produce different limits.
Three failure modes follow. Undetected limits: older Node versions, or runtimes without support for the host’s cgroup version (cgroup v2 support arrived later than v1), see the host’s memory — say 64 GB — and choose a heap limit of several gigabytes inside a 1 GB container. The heap never reaches V8’s limit; instead the container crosses its memory limit and the kernel kills the process with no JavaScript error at all, the scenario in fixing OOMKilled Node.js containers in Kubernetes. Conservative defaults: a correctly detected 4 GB container may get a heap limit of around 2 GB, which surprises teams that provisioned memory for a bigger heap and then see JavaScript heap out of memory far below the container limit. Heap is not the whole process: even a correctly sized heap limit leaves out Buffers and ArrayBuffers, native addons, thread stacks, the code and allocator overhead — the gap explained in reading RSS vs heapUsed in production.
Because the default depends on version, platform and orchestration details you do not control, the robust practice is to set --max-old-space-size explicitly from the container memory limit, leaving deliberate headroom for off-heap memory, and to log the effective limit at startup so drift is visible.
Step-by-Step Fix
- Log the effective limit and the detected memory at startup. Print
v8.getHeapStatistics().heap_size_limit,os.totalmem()and, on Node versions that provide it,process.constrainedMemory(). Verification: logs from each environment show the heap limit Node actually chose. - Compare with the container limit. Read the orchestrator’s memory limit for the pod or task. Verification: you know whether the heap limit is above the container limit (dangerous) or far below it (wasteful).
- Decide the off-heap budget. Measure peak
rss - heapTotalunder load to estimate Buffers, native memory and overhead; add a safety margin. Verification: you have a number such as “off-heap peaks at 300 MB”. - Set
--max-old-space-sizeexplicitly. Use container limit minus off-heap budget minus young-generation size, commonly landing around 60–75% of the container limit. Verification: startup logs show the intended heap limit in every environment. - Derive it automatically where limits vary. Compute the flag in the entrypoint from the cgroup limit, so resized pods get the right value without code changes (see the script below). Verification: changing the pod’s memory limit changes the logged heap limit accordingly.
- Load-test at the limit. Run a soak or stress test until the heap approaches its limit. Verification: the process reports V8 heap exhaustion or sheds load before the kernel kills it; RSS stays under the container limit.
Command and Code Reference
Use case: log what Node detected and chose.
// startup-memory.js — require at the top of the entry file
const v8 = require('node:v8');
const os = require('node:os');
const mb = (n) => Math.round(n / 1048576);
console.log(JSON.stringify({
heapLimitMB: mb(v8.getHeapStatistics().heap_size_limit), // what V8 will allow
totalMemMB: mb(os.totalmem()), // host or VM memory
// constrainedMemory() returns the cgroup limit when one applies (newer Node versions)
constrainedMB: typeof process.constrainedMemory === 'function'
? mb(process.constrainedMemory()) : null,
node: process.version,
}));
Use case: derive --max-old-space-size from the cgroup limit in the container entrypoint.
#!/bin/sh
# entrypoint.sh — works for cgroup v2 (memory.max) and v1 (memory.limit_in_bytes)
if [ -f /sys/fs/cgroup/memory.max ]; then
LIMIT=$(cat /sys/fs/cgroup/memory.max)
else
LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)
fi
if [ "$LIMIT" = "max" ] || [ -z "$LIMIT" ]; then
HEAP_MB=2048 # no limit found: pick a sane default
else
HEAP_MB=$(( LIMIT / 1048576 * 70 / 100 )) # 70% of the container for old space
fi
exec node --max-old-space-size="$HEAP_MB" server.js
Verification and Regression Prevention
The configuration is right when every environment logs a heap limit that is a deliberate fraction of its container limit, a stress test drives V8 to its heap limit (or your load shedding) before the container’s memory limit, and production shows no kernel OOM kills without matching heap-exhaustion signals. Re-check after Node upgrades, base-image changes and cluster migrations — each can change memory detection.
Keep the startup log line permanently and alert when the logged heap limit exceeds the container limit, which catches the most dangerous misconfiguration immediately. For the reasoning behind choosing the fraction, see setting max-old-space-size correctly.
Edge Cases and Gotchas
NODE_OPTIONS and multiple processes
NODE_OPTIONS=--max-old-space-size=… applies to every Node process in the container, including child processes, build tools and cluster workers. If you run several Node processes, their heaps add up; divide the budget accordingly.
Cluster and worker threads
Each cluster worker is a separate process with its own heap limit, and each worker thread has its own isolate with its own limit (settable via resourceLimits). A container running four cluster workers needs roughly four times the heap budget.
Memory requests versus limits
In Kubernetes, the request is used for scheduling and the limit for enforcement. Size the heap from the limit, and keep requests close to realistic usage so pods are not packed onto nodes that cannot actually provide the memory.
Serverless platforms
Platforms such as AWS Lambda set the memory size per function and may set Node flags themselves. Check the effective heap limit from inside the function rather than assuming, as covered in Node.js memory in AWS Lambda.
Frequently Asked Questions
What is the default heap size for Node.js?
It depends on the Node and V8 version and on how much memory Node detects: V8 takes a fraction of the available (or container-constrained) memory, within minimum and maximum bounds. On large machines it is capped at a few gigabytes. Check v8.getHeapStatistics().heap_size_limit in your environment rather than relying on a remembered number.
Does Node.js respect Docker and Kubernetes memory limits?
Recent versions detect cgroup memory limits and size the heap from them. Older versions, or combinations that do not support the host’s cgroup version, may use the host’s total memory and choose a heap larger than the container allows. Setting --max-old-space-size explicitly avoids the dependence.
What percentage of container memory should the heap get?
Commonly 60–75% for the old generation, leaving the rest for the young generation, Buffers, native memory, thread stacks and a safety margin. Services that use many Buffers or native addons need a smaller heap share; pure JavaScript services can use a larger one.
Why was my container killed without a heap error?
The kernel’s OOM killer terminated it because total process memory exceeded the container limit before V8’s heap reached its own limit. That happens when the heap limit is too high for the container, or when off-heap memory grows. Lower the heap limit and investigate RSS versus heap usage.
Related
- Memory Limits and Out-of-Heap Errors in Node.js — the parent topic
- Writing Heap Snapshots Near the Heap Limit — capturing evidence when the limit is reached
- Pointer Compression and the V8 Heap Cage — another build-level factor in heap limits
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview