When a GitHub Actions Job Runs Out of Memory

Exit code 137 means the kernel out-of-memory killer stopped the job. Read the log signature, cap workers and heaps, then price the runner memory ladder.

Last verified:

A GitHub Actions job that ends with Process completed with exit code 137 was killed by the Linux out-of-memory killer, which delivers SIGKILL and leaves no stack trace behind. Fix it in workflow order first, by capping parallel test workers, setting explicit heap limits, and batching tests across jobs, and move up the runner memory ladder only when a single worker no longer fits the machine.

This guide covers the log signature that separates an out-of-memory kill from an ordinary crash, the arithmetic that turns worker counts and heap flags into a memory budget, the Linux runner ladder with the rate at each step, and a monthly model that prices the workflow-side fix against buying a larger machine.

Diagnosis

Exit codes carry the first signal. A shell reports a process that died on a signal as 128 plus the signal number, so SIGKILL (signal 9) surfaces as 137 and SIGTERM (signal 15) surfaces as 143, per signal(7). An ordinary crash exits with a small code such as 1 and prints an error before it goes.

What you seeWhere it appearsWhat it means
Process completed with exit code 137Last line of the failed stepThe process received SIGKILL. With no cancellation in flight, the kernel out-of-memory killer is the usual sender
Out of memory: Killed process 4213 (node) total-vm:...dmesg on the runnerKernel confirmation, naming the process and its resident size at the moment of the kill
FATAL ERROR: Ineffective mark-compacts near heap limitJob log, above a V8 stack traceNode reached its own old-space ceiling while the machine may still have had free RAM (Node CLI options)
java.lang.OutOfMemoryError: Java heap spaceJob log, above a Java stack traceThe JVM hit its -Xmx ceiling, which is a heap setting rather than a machine size
Process completed with exit code 143Last line of the failed stepSIGTERM, which comes from a cancellation or a timeout rather than the out-of-memory killer

Three properties separate the kernel kill from every other failure. The output stops mid-line, because the process never got a chance to flush its buffer. No application-level error precedes it. And the failure moves between test files on reruns of the same commit, because which process the kernel picks depends on the moment memory ran out.

Container jobs add a second killer. When a step runs inside a container with a memory limit, the cgroup enforces that limit before the machine runs dry, and the kill is counted in memory.events under oom_kill (cgroup v2 documentation). Read that file in the same job to tell a container limit apart from a machine limit.

GitHub Actions reports none of this. The job object in the workflow jobs REST API carries timestamps, steps, and a conclusion, with no memory field. WarpBuild agents collect CPU, memory, filesystem, and network utilization from every runner they operate, and the Recommendations view flags any instance at or above 80 percent max memory utilization as High Memory Usage, per the observability documentation. Two collection details shape what you read: metrics cover jobs longer than about one minute, and the reading is machine-wide, so a service container in the same job lands in the same number. Runner level metrics for GitHub Actions jobs walks those reports field by field, and can I alert when a runner runs out of memory covers turning the threshold into a notification.

Fix

Work through the workflow-side levers before you touch the label. Worker count multiplies peak memory while the rate per minute stays flat, so it is the cheapest lever on the list.

1. Measure one worker, then budget the machine

Run the suite with a single worker and record peak resident memory with /usr/bin/time -v. That gives the per-worker figure the whole budget rests on.

workers <= (machine RAM - base overhead) / peak worker RSS

Reserve about 4GB of the machine for the runner agent, service containers, and the page cache that keeps dependency reads off disk. On a 32GB runner with a worker peaking at 3.5GB, the budget allows (32 - 4) / 3.5 = 8 workers. Raise the worker peak to 6GB and the same machine allows (32 - 4) / 6 = 4.6, so four workers is the ceiling and eight workers is the reason the job died.

2. Set explicit heaps

Defaults are sized for the machine rather than for the number of processes sharing it. The JVM sets its maximum heap to one quarter of physical memory when nothing overrides it (HotSpot ergonomics), which permits four unbounded forks to commit an entire 32GB runner. Pin -Xmx per fork, pin NODE_OPTIONS=--max-old-space-size in MiB for Node, and the sum becomes something you can check against the budget above.

3. Batch the tests across jobs

Splitting one job with eight workers into four sharded jobs with two workers each cuts peak memory per machine by a factor of four while the shards run at the same time. Jest exposes --shard, pytest-xdist exposes -n with --dist loadfile, and Gradle exposes maxParallelForks; sizing runners for Java and Gradle builds carries the Gradle arithmetic in full.

4. Then price the ladder

The Linux x64 ladder holds a fixed 4GB per vCPU, so each step doubles both RAM and the rate.

Runner labelvCPURAMRate per minute
warp-ubuntu-latest-x64-2x28GB$0.004
warp-ubuntu-latest-x64-4x416GB$0.008
warp-ubuntu-latest-x64-8x832GB$0.016
warp-ubuntu-latest-x64-16x1664GB$0.032
warp-ubuntu-latest-x64-32x32128GB$0.064

Rates come from the pricing page and bill per minute. The Linux ARM64 ladder carries the same shapes from warp-ubuntu-latest-arm64-2x at $0.003 per minute to warp-ubuntu-latest-arm64-32x at $0.048. Windows runs 16GB at warp-windows-latest-x64-4x up to 128GB at warp-windows-latest-x64-32x, and macOS offers 22GB at warp-macos-latest-arm64-6x and 44GB at warp-macos-latest-arm64-12x. Above 128GB on Linux the ladder ends, so a job that still cannot fit has to shard. Right sizing GitHub Actions runners covers the recommendation endpoint that flags exactly that case.

Configuration

Cap the workers, pin the heap, and leave a diagnostic step in place so the next kill is readable rather than mysterious.

name: tests
on:
  pull_request:

jobs:
  unit-tests:
    runs-on: warp-ubuntu-latest-x64-8x
    env:
      NODE_OPTIONS: --max-old-space-size=3072
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx jest --maxWorkers=6 --workerIdleMemoryLimit=2048
      - name: Report out-of-memory kills
        if: always()
        run: |
          sudo dmesg -T | grep -iE "out of memory|killed process" \
            || echo "no kernel oom kills recorded"
          cat /sys/fs/cgroup/memory.events 2>/dev/null || true

--workerIdleMemoryLimit restarts a worker once it crosses the limit, which contains the leak-shaped growth that kills long suites (Jest CLI reference). The always() step keeps the kernel evidence attached to the failed run.

When one job cannot hold the whole suite, shard it and keep each shard inside the budget.

jobs:
  unit-tests:
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: warp-ubuntu-latest-x64-8x
    env:
      NODE_OPTIONS: --max-old-space-size=3072
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4 --maxWorkers=2

Only the job whose single worker exceeds the machine gets the larger label, and the label is the whole change. Runner labels are documented in the cloud runners reference.

  integration-tests:
    runs-on: warp-ubuntu-latest-x64-16x
    steps:
      - uses: actions/checkout@v4
      - run: pytest -n 4 --dist loadfile

Cost or Time Model

Substitute your own numbers. This fleet runs one test job family for a month on warp-ubuntu-latest-x64-16x at $0.032 per minute, and 15 percent of attempts die at minute 9 with exit code 137 and get rerun.

LineAttemptsMinutes eachMinutesCost
Clean runs2,5501230,600$979.20
Attempts killed at minute 945094,050$129.60
Reruns of those attempts450125,400$172.80
Total3,45040,050$1,281.60

The $129.60 line buys nothing, and the 450 red runs cost engineer attention that the invoice never shows.

Two fixes, priced against that baseline. The first caps workers from eight to six and pins the Node heap, keeping the same label; assume the job stretches to 12.9 minutes and the kills go to zero. Three thousand runs at 12.9 minutes is 38,700 minutes, or $1,238.40, which is $43.20 under the baseline with 450 fewer failures. The second doubles the machine instead: 3,000 runs at 12 minutes on warp-ubuntu-latest-x64-32x is 36,000 minutes at $0.064, or $2,304.00. Doubling the rate to solve a worker-count problem is the expensive path, and it is worth taking only when the measured single-worker peak needs the extra RAM. Replace the 12.9 with a measured duration after a week of runs before you commit either.

Both options price better against GitHub-hosted list prices for identical machine shapes. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, GitHub list price checked on 2026-08-13 in the GitHub Actions billing reference. The capped-worker option costs $1,238.40 here against $1,625.40 at that list price. warp-ubuntu-latest-x64-32x (32 vCPU, 128 GB) costs $0.064 per minute against $0.082 per minute for the 32-core Linux larger runner (32 vCPU, 128 GB): 22 percent lower list price on the same checked date, so the upsize option costs $2,304.00 here against $2,952.00.

The memory readings this guide uses come from CI observability. For the other levers that shorten the same jobs, see speed up GitHub Actions.

FAQ

What does exit code 137 mean in a GitHub Actions job?

The process was killed by SIGKILL, because a shell reports a signalled death as 128 plus the signal number and SIGKILL is signal 9. When no cancellation was in flight, the sender is almost always the kernel out-of-memory killer, and the step output stops mid-line because the process never got to flush it.

How do I tell a kernel out-of-memory kill from an application heap error?

An application heap error prints its own message and a stack trace, then exits with a small code such as 1. java.lang.OutOfMemoryError: Java heap space and the V8 Allocation failed message both belong to that group, and the machine often still has free RAM. A kernel kill prints nothing in the job log, ends at exit code 137, and shows up in dmesg as a Killed process line naming the process and its resident size.

Should I move to a bigger runner or cap parallelism first?

Cap parallelism first, because worker count multiplies peak memory while the rate per minute stays flat. Moving from warp-ubuntu-latest-x64-8x to warp-ubuntu-latest-x64-16x doubles RAM from 32GB to 64GB and doubles the rate from $0.016 to $0.032 per minute, so it is worth doing once the measured peak of a single worker no longer fits the machine.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.