Skip to content
Latchkey LogoLatchkey home

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.

Runner log: V8 fatal error reaching the heap limit, then a green retry at 6144 MB
The recorded run: the step aborts under a 512 MB ceiling, the runner raises NODE_OPTIONS to 6144 MB, the retry prints the finished build.
Diagram of the V8 heap ceiling against runner memory, with the fix and the runner action
The ceiling is a V8 setting, not the machine. A 2 vCPU private-repo runner has 8 GB of RAM; the heap limit is whatever the process was started with.

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.

Actions log, build step
<--- 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

Run 2026-09-19·Runner latchkey-small·Exit code 0·Repaired by NODE_HEAP_OOM

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 19900

Bumped 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

  1. Set NODE_OPTIONS: --max-old-space-size=6144 in the job's env block so every Node process the job starts inherits it, including the ones your scripts fork.
  2. Keep the value under the runner's RAM: 6144 on a standard 8 GB Linux runner, higher only on a larger runner.
  3. Re-run the job and confirm the value arrived by printing the limit V8 is actually using in the step before the build.
.github/workflows/ci.yml
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.

Terminal
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.

.github/workflows/ci.yml
- run: npx jest --maxWorkers=2
# Vitest
- run: npx vitest run --pool=forks --poolOptions.forks.maxForks=2

Put 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.

.github/workflows/ci.yml
jobs:
  build:
    runs-on: latchkey-medium   # 4 vCPU and 16 GB against 2 vCPU and 8 GB
    env:
      NODE_OPTIONS: --max-old-space-size=6144

Choosing 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.

.github/workflows/ci.yml
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 build

Version 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_OPTIONS once 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-Compact figures in a slow build: heap pressure shows up as GC time long before it shows up as a failed job.

Frequently asked questions

How can I prevent an out of memory error in GitHub Actions?
Set the heap ceiling explicitly instead of inheriting whatever Node infers from the machine, keep it below the runner's RAM, and pin the worker count of anything that forks. The failure usually arrives when a repository grows past a default nobody chose, so choosing the value yourself is most of the prevention.
What does NODE_OPTIONS --max-old-space-size actually do?
It sets the maximum size in megabytes of V8's old space, the heap region where long-lived objects live. It does not reserve that memory and it does not give the process more RAM. It only changes the point at which V8 stops trying and aborts, which is why a value larger than the machine converts a clean abort into a kernel kill.
Why does my build pass locally and run out of memory in CI?
Node sizes its default ceiling from the memory it sees, so a 32 GB laptop and an 8 GB runner start the same build with different limits. The build did not change; the ceiling did. Setting the value explicitly in the workflow makes the two environments agree.
Is "JavaScript heap out of memory" the same as exit code 137?
No. A heap-limit abort is V8 stopping itself and exits 134 with a fatal error report in the log. Exit 137 is SIGKILL from outside the process, almost always the kernel out-of-memory killer, and it leaves no V8 report at all. Raising the heap ceiling fixes the first and aggravates the second.

Related guides

References

V8 hit its own ceiling, not the machine's. Latchkey raises the heap limit mid-job and retries the step. Start free → 30-day trial · No credit card