JavaScript heap out of memory in GitHub Actions
"JavaScript heap out of memory" in GitHub Actions means V8 hit the old-space ceiling the process started with, not that the runner ran out of RAM. Raise that ceiling through NODE_OPTIONS and put the job on a runner with the memory to back it.


What this error means
The step stops partway through a build or a test run and prints a V8 crash report: a <--- Last few GCs ---> block, a <--- JS stacktrace ---> header, then the line "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", then a native stack trace through node::OOMErrorHandler. V8 aborts the process itself, so the shell reports Aborted (core dumped) and the step exits 134 rather than 1. Nothing in your code threw. There is no test name, no file and line in your sources, and no exception a try block could have caught. Older Node builds print Ineffective mark-compacts near heap limit where newer ones print Reached heap limit; the two take the same fix.
<--- Last few GCs --->
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
1: 0xb78db3 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]
2: 0xee8300 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node]
3: 0xee85e7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node]
2141 Aborted (core dumped) node "$HOME/build-graph.js"Reproduced on a Latchkey runner
node v20.20.2, NODE_OPTIONS=--max-old-space-size=512
<--- Last few GCs --->
[2141:0x27c94040] 661 ms: Mark-Compact (reduce) 513.7 (518.6) -> 513.6 (515.6) MB, 34.40 / 0.00 ms (+ 1.4 ms in 1 steps since start of marking, biggest step 1.4 ms, walltime since start of marking 41 ms) (average mu = 0.371, current mu = 0.245) fina
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
1: 0xb78db3 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]
2: 0xee8300 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node]
3: 0xee85e7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node]
4: 0x10fb205 [node]
5: 0x1113088 v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [node]
6: 0x1114ffa v8::internal::Heap::HandleGCRequest() [node]
7: 0x1080237 v8::internal::StackGuard::HandleInterrupts() [node]
8: 0x1523dda v8::internal::Runtime_StackGuard(int, unsigned long*, v8::internal::Isolate*) [node]
9: 0x195e476 [node]
/home/runner/.latchkey-job-NPqD2d/_temp/052d33be-de67-4abc-b1cc-554ccf05ed05.sh: line 28: 2141 Aborted (core dumped) node "$HOME/build-graph.js"
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
node v20.20.2, NODE_OPTIONS=--max-old-space-size=6144
module graph built: 200 chunks, checksum 19900Bumped NODE_OPTIONS=--max-old-space-size=6144 and retried Node (heap OOM heal)
Heap abort or kernel kill? The exit code tells you
Two different failures are described with the same words in most write-ups, and they have different fixes. Read the exit code before you change anything.
Exit 134 is V8 giving up first. The process hit its own old-space ceiling, printed the fatal error above, and aborted on SIGABRT. The machine may still have gigabytes free; the ceiling is a setting, not a measurement, so raising NODE_OPTIONS is the fix.
Exit 137 is the kernel giving up first. The process was killed by SIGKILL with no V8 report at all, because it asked the machine for memory the machine did not have. Raising the heap ceiling there makes things worse, not better: it lets Node grow further into a wall it cannot move. That failure is covered in exit code 137 in GitHub Actions.
Common causes
The build needs more heap than the ceiling it was given
V8 will not grow old-space past the limit the process started with, whatever the machine has spare. Bundlers, type-checkers and test runners hold a whole module graph in memory at once, and that working set grows with the repository rather than with the change you pushed.
An inline flag is overriding the environment variable you set
Node gives command-line options precedence over NODE_OPTIONS. A build script that already runs node with --max-old-space-size=2048 keeps its 2048 no matter what the workflow exports, which is the usual reason a raised ceiling appears to do nothing at all.
Each test worker carries its own heap
Jest, Vitest and similar runners fork one process per worker and default their worker count to the machine's core count. Four workers on a 4 vCPU runner means four heaps competing for one pool of RAM, so the per-process ceiling that is comfortable on a laptop is not comfortable in CI.
The ceiling is larger than the memory behind it
A ceiling above the runner's RAM does not fail at the ceiling. The process grows until the kernel intervenes, and the step is killed with no V8 report and exit 137. In our experience this is the failure mode of the first fix people try after reading a generic answer that says to set 8192.
How to fix it
Raise the ceiling for the whole job through NODE_OPTIONS
- Set
NODE_OPTIONS: --max-old-space-size=6144in the job'senvblock so every Node process the job starts inherits it, including the ones your scripts fork. - Keep the value under the runner's RAM: 6144 on a standard 8 GB Linux runner, higher only on a larger runner.
- Re-run the job and confirm the value arrived by printing the limit V8 is actually using in the step before the build.
env:
NODE_OPTIONS: --max-old-space-size=6144
# and, in the step before the build, to prove it arrived:
- run: node -p "v8.getHeapStatistics().heap_size_limit / 1024 / 1024"Find the inline flag that is winning
Command-line options beat the environment, so the hardcoded flag has to go before the environment variable can do anything. Grep the repository for it, remove it from the script, and let the workflow set the value in one place.
grep -rn "max-old-space-size" package.json scripts/ .github/Cap the worker count so the heaps fit in the machine
Fewer, larger workers beat more, smaller ones once memory rather than CPU is the constraint. Two workers with room to breathe finish a suite that four workers abort halfway through.
- run: npx jest --maxWorkers=2
# Vitest
- run: npx vitest run --pool=forks --poolOptions.forks.maxForks=2Put the job on a runner with the memory to back the ceiling
When the peak working set is genuinely larger than the machine, the ceiling is not the problem. A standard private-repository Linux runner gives the job 2 vCPU and 8 GB of RAM; a larger runner or a managed runner sized for the job removes the constraint instead of moving it.
jobs:
build:
runs-on: latchkey-medium # 4 vCPU and 16 GB against 2 vCPU and 8 GB
env:
NODE_OPTIONS: --max-old-space-size=6144Choosing a ceiling that is not a guess
The number you want is the peak heap the build needs, plus headroom, and under the memory the runner actually has. On a standard private-repository Linux runner that is 8 GB of RAM for the whole machine, so a 6 GB ceiling is a sensible upper bound and an 8 GB one is not.
Latchkey's own detection pattern sets 6144 MB for exactly this reason: its recorded rationale is that Node's default ceiling "scales with machine memory, roughly 2 GB on an 8 GB runner and capped near 4 GB on 16 GB+", so a bump "must exceed 4096 to change anything on the larger tiers", while 6144 still leaves headroom on the smallest tier.
For the real number rather than a safe one, watch the Mark-Compact lines in the GC report: the megabyte figure just before the abort is the ceiling the build was fighting.
jobs:
build:
runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max-old-space-size=6144
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: npm run buildVersion notes: Node 18, 20 and 22
The wording of the fatal error changed, not the failure. Node 18 and earlier most often print Ineffective mark-compacts where Node 20 and 22 print Reached heap limit, both followed by Allocation failed. Our reproduction on Node v20.20.2 produced the second form. A third form, CALL_AND_RETRY_LAST Allocation failed, shows up when the allocation that failed was a retry of the last resort. All three are the same condition.
What did change is the default ceiling. Node sizes old-space from the memory it sees, so the same repository builds on a 16 GB runner and aborts on an 8 GB one with no change to your code. That is why this failure so often arrives the week a workflow moves between public and private repository runners.
If raising the ceiling changed nothing
Check that the value is reaching the process. Node documents that "options from the command line take precedence over options passed through the NODE_OPTIONS environment variable", so a script that already passes --max-old-space-size inline wins over anything the workflow sets. Search package.json and any wrapper script for the flag before raising it again.
Check which process is dying, too. A test runner that forks workers gives each worker its own heap, so the abort in the log belongs to a worker rather than to the command you launched.
If the ceiling is high, the flag reaches the process and the step still dies, read the exit code again. A step that moved from 134 to 137 is no longer a heap problem; it is the machine.
How to prevent it
- Set
NODE_OPTIONSonce at the job level rather than per step, so forked processes inherit it. - Keep the ceiling below the runner's RAM, and raise the runner rather than the ceiling when the two collide.
- Pin the worker count of test runners in CI instead of letting them read the core count.
- Watch the
Mark-Compactfigures in a slow build: heap pressure shows up as GC time long before it shows up as a failed job.