# JavaScript heap out of memory in GitHub Actions

> Fix "JavaScript heap out of memory" in GitHub Actions: raise the V8 old-space ceiling through NODE_OPTIONS, and size the runner to back it.

Source: https://latchkey.dev/learn/failures/javascript-heap-out-of-memory-in-ci  
Updated: 2026-09-19

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

```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"
```

## 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
```

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

## 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](/learn/failures/exit-code-137-in-github-actions).

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

## FAQ

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

## References

- [Node.js CLI: NODE_OPTIONS and option precedence](https://nodejs.org/api/cli.html#node_optionsoptions)
- [Node.js CLI: --max-old-space-size](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-mib)
- [GitHub-hosted runners: standard runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
- [actions/runner-images: memory limits on hosted runners](https://github.com/actions/runner-images/issues/70)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
