Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)
Hi, it's BlueByte. A build or a script that ran fine last month now dies with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, a wall of <--- Last few GCs ---> lines above it, and exit code 134. The machine has plenty of RAM, so the number that ran out is not the one you were watching. We'll walk through what the V8 heap limit is, how to read the limit your Node is actually using, the three situations that reach it, the fix for each, and how to tell this apart from a container kill that looks similar.
What the heap limit is and why a 16 GB machine still hits it
Node runs JavaScript inside V8, and V8 keeps long-lived objects in a region it calls old space. That region has its own ceiling, separate from the memory in the machine. Node's CLI docs describe --max-old-space-size as the setting for "the max memory size of V8's old memory section", and add that as consumption approaches the limit, V8 spends more time on garbage collection trying to free memory. That is the Mark-Compact storm you see just before the crash. The default ceiling is derived from the system memory and the Node version, so it is often far below the RAM you have. Nothing is broken here: the process asked for more than its own ceiling, and V8 refused.
The message has a few variants. Current releases print Reached heap limit Allocation failed - JavaScript heap out of memory; older ones print Ineffective mark-compacts near heap limit Allocation failed or CALL_AND_RETRY_LAST Allocation failed. All three are the same event, and the checks below apply to each.
First, read the number V8 is actually working with
Do not guess the default — ask V8. On the Node 22.23.1 box we checked, a 24 GB host, the answer was about 4 GiB:
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1048576)"4144
Your number will differ; it depends on your Node release and on how much memory the system reports. Two observations from that check matter for the fix. Passing --max-old-space-size=4096 printed the same 4144, because the default there was already 4 GiB, so a flag that "raises" the limit to a value below or near the default changes nothing. Passing NODE_OPTIONS=--max-old-space-size=2048 printed 2096, which shows the flag is honoured from the environment.
Three reasons a process reaches the limit
A large but legitimate workload. Bundlers, TypeScript type-checking and framework builds hold whole dependency graphs in memory. If the build needs 5 GiB and the ceiling is 4 GiB, it dies at the same point every run.
A container smaller than the heap. In a pod or a CI job with a 2 GiB memory limit, V8 sizes its heap from what it can see of the system memory, and whether a container limit is taken into account depends on the Node release. Then one of two things happens: the cgroup kills the process first (exit 137, no FATAL ERROR line), or the heap ceiling is reached first and you get this error. Which one you see depends on which limit is lower.
A leak in a long-running process. A server that reaches the limit after hours, not seconds, is accumulating objects. Raising the limit only delays the crash.
The run-to-run pattern tells them apart: same point every time is a workload; after a long uptime is a leak; only inside a container is sizing.
Fix 1: raise the old-space limit for one command or the whole shell
The flag takes the size in MiB. For a single command:
node --max-old-space-size=6144 node_modules/.bin/next buildFor everything a shell or a CI step runs, use NODE_OPTIONS. The docs describe it as a space-separated list of options interpreted before the command line, and list --max-old-space-size among the V8 options allowed there, so this works for npm run build and child processes alike:
export NODE_OPTIONS=--max-old-space-size=6144
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1048576)"6192
Leave headroom: the docs' own example is a 2 GiB machine set to 1536 to avoid swapping.
Fix 2: inside a container, size the heap below the memory limit
Set the flag a few hundred MiB under the container's memory limit so V8 collects garbage before the cgroup kills the process. The current CLI docs also list --max-old-space-size-percentage, which sets old space as a percentage of available system memory and takes precedence over the absolute flag when both are given. If you check it yourself, on the 24 GB host above --max-old-space-size-percentage=50 printed 12035. It is a newer flag, so run node --help | grep percentage on your release before you rely on it in a Dockerfile.
Fix 3: a growing process — snapshot it near the limit
For a leak you need to see what is growing. --heapsnapshot-near-heap-limit=max_count writes a V8 heap snapshot to disk when usage approaches the limit, up to max_count files, and the docs note that a collection may drop usage so several snapshots can land before the process finally dies. As of v25.4.0, v24.13.1 and v22.22.1 the flag is no longer experimental.
node --heapsnapshot-near-heap-limit=3 --max-old-space-size=1024 server.jsHeap.20200430.100036.49580.0.001.heapsnapshot
Heap.20200430.100037.49580.0.002.heapsnapshot
Load two consecutive files in Chrome DevTools' Memory tab and compare them; the objects that grew between snapshots are the leak. The docs warn that snapshots cost memory of their own, so do this in a staging copy with a smaller limit, not on the production process.
A real case: next build dying on a 2-vCPU CI runner
A team's next build passed on laptops and failed on a 7 GB CI runner with Reached heap limit. The first instinct was to add --max-old-space-size=8192, which the runner could not honour. The heap-statistics check on the runner, which still ran an older Node, came back well under the 7 GB available, so V8 had sized itself far below the machine. NODE_OPTIONS=--max-old-space-size=5120 in the job's environment let the build finish in one go, and the same variable in the Dockerfile's build stage kept the image build identical.
Check it end to end and keep it from coming back
Verify with the same one-liner, run from the exact environment that failed, and read the number, not the exit code. Then pin the setting where the command lives: the build script in package.json, the CI job's env, or ENV NODE_OPTIONS in the Dockerfile, so the next machine does not rediscover it. For servers, graph process.memoryUsage().heapUsed and alert on a rising floor.
Exit 134 vs exit 137, and the stack error people confuse with this
This crash exits with 134 and prints the FATAL ERROR line, because V8 aborted itself. Exit 137 with no Node output is the kernel or a container runtime killing the process for exceeding its memory limit; we cover that in the Kubernetes OOMKilled guide. RangeError: Maximum call stack size exceeded is a different limit again: the call stack, usually from unbounded recursion, and no heap flag helps.
Next time you hit this, walk these checks back in order: read the limit V8 is using, decide from the run pattern whether it is workload, container or leak, then set the flag where the command lives.
Related questions
Why does raising --max-old-space-size sometimes change nothing?
Because the value you passed is at or below the default V8 already chose. On a 24 GB host running Node 22.23.1, the default was 4144 MiB and --max-old-space-size=4096 printed the same number. Read heap_size_limit first, then set a value clearly above it and below what the machine or container can give.
Is NODE_OPTIONS safe for this, or must the flag be on the command line?
The Node CLI docs list --max-old-space-size among the V8 options allowed in NODE_OPTIONS, and options there are interpreted before command-line ones, so a flag on the command line overrides it. NODE_OPTIONS is the right place for npm scripts and CI steps because it reaches child processes too.
How much should I set inside a container?
A few hundred MiB below the container's memory limit, so V8 collects garbage before the cgroup kills the process. The docs' own example is a 2 GiB machine set to 1536. If your release supports --max-old-space-size-percentage, check what it resolves to with the heap-statistics one-liner before relying on it.
The server dies after hours, not at startup. Will a bigger heap fix it?
No, it only delays the crash. That pattern is a leak. Run a staging copy with --heapsnapshot-near-heap-limit=3 and a smaller --max-old-space-size, compare two consecutive snapshots in Chrome DevTools, and fix whatever grew between them.
How is this different from OOMKilled in Kubernetes?
This crash is V8 aborting itself: exit 134 and the FATAL ERROR line in the process output. OOMKilled is the kernel enforcing the cgroup memory limit: exit 137 and no Node output. If you see 137, the container limit is lower than the heap; size the heap under it.
References
- Node.js docs — Command-line API: --max-old-space-size (MiB, 2 GiB → 1536 example), --max-old-space-size-percentage, NODE_OPTIONS (allowed V8 options, precedence)
- Node.js docs — Command-line API: --heapsnapshot-near-heap-limit=max_count (snapshot near the heap limit, no longer experimental as of v25.4.0 / v24.13.1 / v22.22.1)
- Node.js docs — V8 module: v8.getHeapStatistics() (heap_size_limit, used_heap_size)
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
More in this category
Git: fatal: detected dubious ownership in repository
Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.
Kubernetes: Internal error occurred: failed calling webhook
An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.
Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang
The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.
curl: (60) SSL certificate problem: unable to get local issuer certificate
curl walked the certificate chain the server sent and reached a certificate whose issuer is not in the CA store it is reading, so it refused the connection with exit code 60. The issuer is missing for one of four reasons: the server sends only its leaf certificate without the intermediate (browsers hide this by fetching it themselves), a TLS-inspecting proxy re-signed the site with a company CA that the container or runner does not trust, curl is reading a different CA bundle than you think (CURL_CA_BUNDLE, SSL_CERT_FILE, a vendored curl), or the ca-certificates package is too old. curl -v shows which store was used and openssl s_client shows what the server sent; fix the side that is missing the link and confirm with -w '%{ssl_verify_result}'.