Skipbox

GitHub Actions "exit code 137" or "JavaScript heap out of memory": why the job was killed and how to fix it

Exit code 137 is 128 + 9: the process was killed with signal 9, SIGKILL, and on a GitHub-hosted runner that almost always means the Linux out-of-memory killer stopped it because the job used more than the runner's memory, 8 GB on ubuntu-latest in a private repository (GitHub Docs, read on November 9, 2026). When the whole runner runs out before anything can be killed, the log says "The runner has received a shutdown signal" instead. Cap the tool's memory (Node's --max-old-space-size, Gradle's and the JVM's -Xmx, fewer test workers or parallel jobs), add swap, or run the job on a machine with more memory.

Skipbox publishes this guide and sells a runner. The GitHub, Node, Gradle and tool facts here come from their own documentation, read on November 9, 2026 and linked under Sources.

What 137 means

The Bash manual: "When a command terminates on a fatal signal whose number is N, Bash uses the value 128+N as the exit status." Signal 9 is SIGKILL, and the signal(7) man page says "The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored." So the process got no chance to print an error. It was stopped from outside.

On a hosted runner, nobody sends SIGKILL to your build by hand. It's the kernel's out-of-memory killer, which picks a process to kill when the machine has no memory left. The messages you may see:

To confirm, add a step after the failing one that runs even when it fails, and read the kernel log. Hosted runners give you passwordless sudo:

- name: Out-of-memory check
  if: failure()
  run: |
    free -h
    sudo dmesg | grep -iE "out of memory|killed process" || true

A line like Out of memory: Killed process 1234 (node) names the process. If the runner itself went down, this step never runs: that's the shutdown-signal case. In runner-images #15202, opened on October 31, 2026, a GitHub maintainer tested ubuntu-24.04 runs past their memory and wrote that "once memory runs out, the VM stops responding before an OOM message can reach the log." In #15179 (October 28, 2026, ubuntu-26.04), the cause was golangci-lint v1.64.8 with Go 1.26, which a maintainer measured at 15 GB of memory against 2.5 GB with Go 1.24.

How much memory the runner has

The same workflow can pass in a public repository and fail in a private one: ubuntu-latest has half the memory there. Your job doesn't get all of it either. The runner application, Docker and anything else on the machine take their share. ubuntu-latest runner specs has the disk and CPU too, and a step that prints what a runner has.

Node: the heap limit

"JavaScript heap out of memory" and "Reached heap limit" come from V8, Node's JavaScript engine, when the program's heap reaches V8's limit. That limit isn't the runner's memory, so the runner can have memory to spare. Raise it with --max-old-space-size, in MiB, through NODE_OPTIONS:

- run: npm run build
  env:
    NODE_OPTIONS: --max-old-space-size=4096

Node's documentation gives the rule of thumb: "On a machine with 2 GiB of memory, consider setting this to 1536 (1.5 GiB) to leave some memory for other uses and avoid swapping." On an 8 GB runner, 4096 leaves room for the rest of the job. Set it above the runner's memory and the heap error turns into exit code 137.

If the build runs several Node processes at once (a monorepo build, test workers), each one gets that limit. Four workers at 4096 can ask for 16 GB.

Gradle, Maven and other JVMs

The JVM's heap is set by -Xmx. For Gradle, org.gradle.jvmargs sets the daemon's JVM arguments, and its default is -Xmx512m "-XX:MaxMetaspaceSize=384m". In gradle.properties:

org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m
org.gradle.workers.max=2

org.gradle.workers.max defaults to the number of processors, and with org.gradle.parallel=true Gradle forks up to that many JVMs, each with its own heap on top of the daemon's. Add them up against the runner's memory, and lower the workers before you raise -Xmx.

For Maven, MAVEN_OPTS holds the options of the JVM that runs Maven, memory settings included (for example -Xmx3g). A test JVM that Maven forks has a heap of its own on top of it.

Rust, C++ and parallel builds

Compilers and linkers use memory per job, and a parallel build runs one job per core by default. Cargo's build.jobs defaults to the number of logical CPUs and reads CARGO_BUILD_JOBS:

- run: cargo build --release
  env:
    CARGO_BUILD_JOBS: 1

For make, pass a smaller -j. Fewer jobs at once means a slower build that fits in the runner's memory.

Test runners: Jest, Vitest and Playwright

Test runners start several workers, each a process with its own memory:

  • Jest runs, by default, "the number of the cores available on your machine minus one for the main thread". Lower it with --maxWorkers=2 or --maxWorkers=50%, or run in one process with --runInBand.
  • Vitest "uses all available parallelism" when watch is off. Lower it with --maxWorkers=2 or --maxWorkers=50%.
  • Playwright sets workers in its config; its own example uses workers: process.env.CI ? 2 : undefined. Each worker drives its own browser.
- run: npx vitest run --maxWorkers=2

Docker builds and docker compose services

Containers share the runner's memory with the job. A database, a cache and a browser started by docker compose before the tests can take half of an 8 GB runner before the first test runs. Two checks:

  • Run docker stats --no-stream in a step to see what each container uses.
  • Cap each service in compose.yaml, so one container fails clearly instead of taking the runner down:
services:
  db:
    image: postgres:17
    deploy:
      resources:
        limits:
          memory: 1g

A docker build whose RUN step is killed with 137 has the same causes as the job itself: lower that tool's workers or heap inside the Dockerfile.

Add swap on the runner

Swap lets the kernel move memory it isn't using to disk. It makes an over-budget job slower instead of killed, and it doesn't help a job that really needs more memory at once. Check what's there with free -h (the reporter in #15202 found a 3 GiB swapfile on an 8 GB ubuntu-24.04 runner), then add a step before the heavy one:

- name: Add swap
  run: |
    sudo fallocate -l 8G /swapfile2
    sudo chmod 600 /swapfile2
    sudo mkswap /swapfile2
    sudo swapon /swapfile2
    free -h

The runner's disk is limited too: 14 GB of SSD on ubuntu-latest, so keep the swapfile well under the free space df -h shows.

When the job needs more memory

If the job really uses more than the runner has, give it a bigger machine:

GitHub's larger runners have 4 GB per core and are available to organizations on GitHub Team and Enterprise Cloud, billed per minute and rounded up per job. Skipbox's VMs have 2 GB per vCPU, so for the same memory a Skipbox job has more cores and a GitHub one fewer. The free cost calculator prices your own jobs from your usage report.

Skipbox runs Linux x64 jobs from private repositories of a GitHub organization or a personal account, each in a fresh Ubuntu 24.04 VM in Helsinki that is destroyed after the job, with passwordless sudo and Docker as on GitHub's runners. A plan's price is the same however many minutes you run. A job asks for its size by label:

jobs:
  build:
    runs-on: skipbox-8x16

The trial and runs-on: skipbox won't fix an out-of-memory job. Both are 4 vCPU and 8 GB, no more memory than ubuntu-latest's 8 GB on a private repository. The trial runs one runs-on: skipbox job at a time and no larger size, so it can show you a Skipbox runner but not a bigger one. skipbox-8x16 needs plan M or L, and skipbox-16x32 needs plan L; a job larger than your account's largest plan is refused, and your account's page shows why.

macOS, Windows, Arm and public-repository jobs, and jobs that need more than 32 GB, stay on GitHub.

FAQ

What does exit code 137 mean in GitHub Actions?

The process was killed by signal 9, SIGKILL: a shell reports a process killed by signal N as exit status 128 + N. SIGKILL can't be caught or ignored. On a GitHub-hosted runner it almost always comes from the Linux out-of-memory killer, when the job's processes used more memory than the runner has.

How much memory does a GitHub Actions runner have?

ubuntu-latest has 8 GB of memory and 2 cores in a private repository, and 16 GB and 4 cores in a public one. ubuntu-slim has 5 GB. Larger runners have 4 GB per core: 16 GB at 4 cores, 32 GB at 8 and 64 GB at 16 (GitHub Docs, read November 9, 2026).

What's the difference between exit code 137 and "JavaScript heap out of memory"?

"JavaScript heap out of memory" (or "Reached heap limit") means Node stopped at its own heap limit, which you can raise with --max-old-space-size. Exit code 137 means the kernel killed the process because the whole runner ran out of memory, so raising Node's limit makes it worse: the fix there is to use less memory or a bigger runner.

Why does my job end with "The runner has received a shutdown signal"?

One common cause is the whole runner running out of memory. In runner-images issue #15202 (October 31, 2026), a GitHub maintainer wrote that "once memory runs out, the VM stops responding before an OOM message can reach the log", so the log shows the shutdown message instead of exit code 137.

Does Skipbox's free trial fix an out-of-memory job?

No. The trial runs runs-on: skipbox jobs only, at 4 vCPU and 8 GB, which is no more memory than ubuntu-latest's 8 GB on a private repository. More memory on Skipbox means runs-on: skipbox-8x16 (16 GB) on plan M or L, or runs-on: skipbox-16x32 (32 GB) on plan L.

Sources

All read on 2026-11-09.

Updated November 9, 2026

See your own GitHub Actions bill

The free cost calculator reads your repositories' last 30 days of runs and gives one fixed monthly amount for the same jobs.

Open the cost calculator

Flat-price CI from your next push

Change one runs-on: line. Any minutes, one price.

  • No card
  • 7 days or 3,000 job-minutes free
  • 30-day refund on first payment