Skip to content

Blog

How much memory does a GitHub Actions runner have, and how do I fix out-of-memory errors?

·

A standard GitHub-hosted Linux runner has 2 vCPU and 8 GB of memory in a private repository, and 4 vCPU and 16 GB in a public one. A job that needs more is killed, often with exit code 137. Measure the peak, cut parallel workers or raise the Node heap, and size up only if it does not fit.

The rest of this post is the figures, the three signatures of an out-of-memory kill, two ways to measure a job’s peak, the fixes that cost nothing, and how to pick a size. The GitHub facts were read from GitHub’s own documentation on 10 November 2026; where something is ours, it says so.

How many CPU cores and how much memory do GitHub-hosted runners have?

A standard Linux runner has 2 vCPU and 8 GB in a private repository and 4 vCPU and 16 GB in a public one. GitHub’s runner reference gives the same split for Windows and for ARM64, and lists ubuntu-slim at 1 vCPU and 5 GB in both. The page carries no dates, so check it again before you size anything.

The split has a practical consequence. A suite that passes in a public repository can be killed in the private fork of the same code, because it has half the memory and half the cores. The table sets each GitHub size beside the label that replaces it on our runners.

SizeGitHub vCPUGitHub memoryGitHub labelOur labelOur memory
1 vCPU15 GBubuntu-slimrunnersio-1vcpu-ubuntu-24044 GB
2 vCPU28 GBubuntu-latest, private repositoriesrunnersio-2vcpu-ubuntu-24047 GB
4 vCPU416 GBubuntu-latest, public repositoriesrunnersio-4vcpu-ubuntu-240415 GB

Ours is under GitHub’s at each size in the table: 4 GB against 5 at 1 vCPU, 7 against 8 at 2, and 15 against 16 at 4. A job sitting close to GitHub’s limit can need the next size up here, and the migration pull request flags the gap when it sees one. GitHub also sells larger runners, from 2 vCPU and 8 GB up to 96 vCPU and 384 GB, according to its larger-runner reference. Either way the figure on the label is the whole machine: the operating system, the runner agent and any Docker daemon share it with your job.

How do I recognise an OOM kill?

There are three signatures, and they point at different layers. Exit code 137 means the kernel killed the process; a heap error means Node gave up at its own cap; and a lost-runner message means the runner agent stopped answering, which starving the machine can cause.

Exit code 137. Bash reports a command that dies from a fatal signal as 128 plus the signal number, and the kernel’s out-of-memory killer uses SIGKILL, which is signal 9. So 128 + 9 = 137. Linux’s documentation says the killer picks a task to sacrifice when memory cannot be reclaimed, which is why the step often shows nothing but Killed and a status. Anything else that sends SIGKILL gives 137 too, so confirm it. In a step with if: failure(), run sudo dmesg | tail -50 and look for Out of memory or Killed process, where the kernel log is readable.

“JavaScript heap out of memory”. This is Node hitting V8’s own limit on its old-generation heap, which --max-old-space-size sets. It can happen on a machine with memory to spare, and the fix is the opposite of the kill above: raise the cap.

“Lost communication” or “shutdown signal”. When the runner agent itself is starved, the run ends with a message that the runner lost communication with the server (the linked report is from a self-hosted runner), and the text says that anything in the workflow that starves the runner of CPU or memory can cause it. “The runner has received a shutdown signal” is vaguer still: the report in GitHub’s own tracker was closed with no cause given. Treat both as symptoms. They call for a measurement, which is next, before a conclusion.

How do I measure a job’s peak memory and CPU use?

Two measurements cover most jobs. The first is /usr/bin/time -v, which prints Maximum resident set size in kilobytes when the command ends. The time package is in GitHub’s Ubuntu 24.04 image list. The figure is the largest single process, not the sum, so a test runner with eight workers reads as one worker.

- name: Run the tests and report the largest process run: | status=0 /usr/bin/time -v -o time.log npm test || status=$? grep -E "Maximum resident|Elapsed" time.log exit "$status"

The second sees the whole machine. A background loop writes used memory, available memory and the load average every five seconds, and a final step reports the extremes even if the tests failed. Lowest available memory is the number to watch: the kernel’s killer acts when it cannot reclaim any more.

- name: Start the sampler run: | cat > "$RUNNER_TEMP/sample.sh" <<'SH' while true; do echo "$(date +%T) $(free -m | awk '/^Mem:/ {print $3, $7}') $(cut -d' ' -f1 /proc/loadavg)" sleep 5 done SH nohup bash "$RUNNER_TEMP/sample.sh" > "$RUNNER_TEMP/mem.log" 2>&1 & - run: npm test - name: Report the peaks if: always() run: | awk 'NR == 1 || $2 > used { used = $2 } NR == 1 || $3 < avail { avail = $3 } NR == 1 || $4 > load { load = $4 } END { print "peak used MB:", used, "| lowest available MB:", avail, "| peak load:", load }' "$RUNNER_TEMP/mem.log"

Load average above the vCPU count means the cores were oversubscribed. On our runners the job page also has a Resources tab covering memory, disk, CPU and network over the run, described on the jobs page, so a run can be read without adding a step.

What fixes it without a bigger machine?

Three changes, in the order worth trying: run fewer things at once, cap what each process may take, and add swap as a last resort.

Fewer workers. Memory use scales with parallelism. Jest starts one worker per core minus one by default, and its documentation suggests lowering that in resource-limited CI. Each worker is a process with its own heap, so fewer workers means a lower peak. make -j and a bundler’s thread count behave the same way. To keep the parallelism, split the suite across a matrix instead, with a machine per leg, as the post on running less shows.

A heap cap that fits. Node accepts --max-old-space-size in NODE_OPTIONS. Its documentation advises leaving headroom: on a machine with 2 GiB it suggests 1536. Every child process inherits the variable, so a cap of 3072 with four workers can ask for 12 GB, and even two workers and the parent process at 3072 can reach 9 GB. On an 8 GB machine, 2048 with two workers keeps the three processes to 6 GB.

jobs: test: runs-on: ubuntu-latest env: NODE_OPTIONS: --max-old-space-size=2048 # 3 processes x 2 GB on an 8 GB machine steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - run: npx jest --maxWorkers=2

Swap. Check swapon --show first. On an Ubuntu runner a job has passwordless sudo, on GitHub’s and on ours, so a swap file is four lines. Our guest kernel is built with swap support, but we have not run this recipe on our runners, so check swapon --show after it. It turns a kill into slowness, and slowness is billed by the minute and bounded by the job limit (60 minutes on ours, per the limits page). Use it to get a job past a rare peak, not to carry a steady load.

- name: Add 4 GB of swap run: | swapon --show sudo fallocate -l 4G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile free -m

How do I choose the right runner size for a job?

Pick the smallest size whose memory holds your measured peak with room to spare. Take the peak from the sampler, add a margin of a quarter or more (our rule of thumb, not a published figure), and read down the memory column. Memory rises with vCPU, so a size is chosen by whichever you need more of.

LabelvCPUMemory
runnersio-1vcpu-ubuntu-240414 GB
runnersio-2vcpu-ubuntu-240427 GB
runnersio-4vcpu-ubuntu-2404415 GB
runnersio-8vcpu-ubuntu-2404830 GB
runnersio-16vcpu-ubuntu-24041660 GB
runnersio-32vcpu-ubuntu-240432125 GB

That is the whole range, from 1 vCPU and 4 GB to 32 vCPU and 125 GB, and the labels page has the other images and the ARM and Windows sizes. The per-minute rate doubles with each step (the pricing page has the figures), so a memory-hungry job that uses one core pays for cores it does not use. That is the case for trimming the peak before buying past it. The calculator prices a size against your own minutes.

In order, then: read the signature, measure the peak, lower the parallelism, set a heap cap that fits, and only then pay for a larger size. Most out-of-memory failures in a test or build step end at the second or third of those, and a size chosen from a measurement stays right until the code changes, where a size chosen after a failure is usually a guess.

Two checks before you move up. Re-run the sampler on the new size, since a job that was starved can use more once it is not. And a step-by-step move from GitHub’s labels is on the moving from GitHub-hosted runners page.

Run your CI and your infrastructure on one bill

Connect GitHub

Card required. You pay only for the minutes your jobs run.