Why Are My GitHub Actions Builds Slow?
GitHub Actions builds are slow for four reasons: queue wait before the job starts, cold caches, undersized runners, and emulated builds. Measure each one.
Last verified:
GitHub Actions builds are slow for four reasons, and they show up in this order for most teams: the job waits in the queue before it starts, the cache is cold so dependencies download and rebuild from scratch, the runner is undersized for the work, and the build emulates a second CPU architecture instead of building natively. Its Queue Timings and Jobs reports split queue wait from execution time so you can tell which of the four applies to your repository before changing a line of workflow YAML.
Answer
The word "slow" collapses two different measurements. The number a developer feels is the wall clock gap between pushing a commit and seeing a green check. The number a workflow file controls is execution time, which starts when a runner picks the job up. Everything between the push and the pickup is queue wait, and queue wait is invisible in the step timings GitHub prints in the job log.
Rank the four causes in this order, because that is the order in which they are cheap to check.
| Rank | Cause | Symptom you see | Where to confirm it |
|---|---|---|---|
| 1 | Queue wait before the job starts | Job sits in "Queued" with no logs, step timings look normal | Queue Timings report, queue time P75 and P90 per runner label |
| 2 | Cold cache | Dependency install and compile steps take the same time on every run | Cache hit or miss output in the job log, cache billing table |
| 3 | Undersized runner | One long step pinned at high CPU or memory, everything else fast | Jobs report duration percentiles plus CPU and memory percentiles |
| 4 | Emulated build | A multi architecture Docker build step dominates the job | Build step duration compared against a native single architecture run |
Queue wait ranks first because it is the only cause that a workflow change cannot fix. If jobs are queuing behind a fixed pool of runners, a faster machine, a warmer cache, and a native build all shorten a number that nobody is waiting on. Fix the queue first, then measure again.
Concurrency is the structural version of that problem. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That removes queue wait as a variable and lets the remaining three causes show up cleanly in the Jobs report.
This page is the diagnosis half of the topic. Once you know which cause you have, the task by task fixes live in the guide to speeding up GitHub Actions workflows.
Detail
Every number below comes from one of four sources: the WarpBuild cloud runners documentation, the WarpBuild reports documentation, the WarpBuild runner catalog and per minute rates published on the WarpBuild pricing page, or GitHub's published list prices in the GitHub Actions minute multipliers reference, checked on 2026-08-13.
Cause 1: queue wait before the job starts
A GitHub Actions job stays queued until an online runner in scope advertises every label in runs-on. Two things keep it there. Either no runner carries the label set, which is usually a typo, or every runner that does carry it is busy with another job. The first case never resolves and the job eventually expires. The second case resolves at a rate set by your runner pool, which is why queue wait grows superlinearly during the busy part of the day and disappears overnight.
Queue wait is the cause most often misread as a slow build, because GitHub's job page shows total elapsed time and the step list underneath it shows execution time. The gap between them is queue wait, and nothing in the workflow file changes it.
The Queue Timings report reports this directly. Each row is a unique runner label and stack combination, with run count, queue time P75, and queue time P90. A daily bar chart shows average queue time next to total job count per day, so a queue that grows with volume looks different from a queue that grows because a label went stale.
Cause 2: cold caches
A cold cache makes every run pay the full cost of dependency resolution, download, and compilation. The symptom is a dependency step whose duration barely varies between a run that changed one line and a run that changed a thousand.
WarpBuild caching is enabled by default on Linux runners and is not supported on Windows runners, per the cloud runners documentation. The WarpBuilds/cache action is a drop in replacement for actions/cache@v4, so the migration is one line.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Restore dependency cache
uses: WarpBuilds/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- run: npm ci
- run: npm testThree cache failures account for most cold cache reports. A key built from a timestamp or a commit SHA never hits. A key built from a lock file hits only when the lock file is unchanged, which is correct but means a dependency bump costs a full cold run. And a cache that is evicted between runs behaves exactly like a cache that was never written. Confirm by comparing the cache step output across two consecutive runs of the same workflow on the same branch.
Cache work is billed separately from runner minutes:
| Item | Rate | Unit |
|---|---|---|
| Cache storage | $0.20 | per GB month |
| Cache write or restore | $0.0001 | per operation |
| Snapshot restore | $0.04 | per job |
| Snapshot storage | $0.025 | per snapshot hour |
| Networking (Tailscale) | $0 | per minute |
Worked example. A repository holding 40 GB of cache and running 2,000 jobs per month, each job doing one restore and one write, pays 40 x $0.20 = $8.00 in storage and 2,000 x 2 x $0.0001 = $0.40 in operations, for $8.40 per month. Sizing and eviction behavior are covered in the guide to the GitHub Actions cache size limit.
When the cold start is the machine image rather than the dependency cache, snapshot runners capture a runner VM mid workflow and boot later jobs from that snapshot. The label carries the snapshot key:
jobs:
integration-tests:
runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=my-project-snapshot
steps:
- uses: actions/checkout@v4
- run: ./scripts/integration-tests.shAt $0.04 per snapshot restore, 2,000 jobs per month cost $80.00 in restores, plus $0.025 per snapshot hour, or $18.25 for one snapshot held for a 730 hour month. That is $98.25 per month on top of runner minutes, or about $0.049 per job. At $0.008 per minute on a 4 vCPU runner, snapshots earn their place when they remove more than about 6 minutes of setup per job.
Cause 3: undersized runners
A job is runner bound when one step pins CPU, memory, or disk throughput while wall clock time stretches. The WarpBuild observability recommendations view flags this with fixed thresholds:
| Metric | Under provisioned threshold | Alert label |
|---|---|---|
| Max sustained CPU | 80 percent or higher | High CPU Usage |
| Max memory utilization | 80 percent or higher | High Memory Usage |
| Max filesystem utilization | 80 percent or higher | High Filesystem Usage |
| Max disk I/O | 80 percent or higher of supported throughput | High Disk IO |
Resizing helps only when the work parallelizes. A single threaded test suite on a 32 vCPU runner finishes at the same wall clock as it does on a 4 vCPU runner and costs eight times as much per minute, because the Linux x64 catalog is priced linearly:
| Runner label | vCPU | RAM | Storage | Per minute | Per vCPU minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | $0.002 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | $0.002 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | $0.002 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | $0.002 |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | $0.002 |
The per vCPU minute column is the whole decision. Doubling cores doubles the per minute rate, so a resize is cost neutral exactly when it halves wall clock time, better than neutral when the job scales past that, and worse when it does not. Measure peak CPU in the Jobs report before you resize, and measure duration P75 after.
Cause 4: emulated builds
The fourth cause is a multi architecture Docker build running one of its architectures under emulation. A single x64 runner producing a linux/amd64 and linux/arm64 image builds the ARM64 half through QEMU, which executes the same compiler work through a translation layer. The build succeeds and the timings look terrible, and the job log gives no obvious signal beyond a long build step.
The structural fix is native hardware for each architecture. Split the build into a matrix and let each leg run on its own architecture:
name: docker
on:
push:
branches: [main]
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: warp-ubuntu-latest-x64-8x
- platform: linux/arm64
runner: warp-ubuntu-latest-arm64-8x
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Build and push
uses: Warpbuilds/build-push-action@v6
with:
context: .
push: true
platforms: ${{ matrix.platform }}
tags: user/app:${{ github.sha }}
profile-name: "release-builder"Worked example with illustrative durations you should replace with your own numbers from the Jobs report. Suppose the single job emulated build takes 30 minutes on warp-ubuntu-latest-x64-8x. That is 30 x $0.016 = $0.48 per build, and 30 minutes of wall clock. Suppose each native leg takes 8 minutes. The x64 leg costs 8 x $0.016 = $0.128, the ARM64 leg costs 8 x $0.012 = $0.096, the pair costs $0.224 per build, and wall clock is the longer leg at 8 minutes rather than the sum. The arithmetic is what matters here, not the assumed durations: two shorter native jobs beat one long emulated job on both axes whenever emulation costs more than the ratio of the two per minute rates.
Remote Docker builders take the layer cache off the ephemeral runner entirely. Each builder profile is a dedicated build VM with a persistent layer cache on local disk, and multiple jobs share one profile in parallel. Builder rates run from $0.06 to $0.88 per minute on the pricing page.
The measurement path: split queue time from execution time
This is the one procedure worth running before any workflow change. It takes about ten minutes.
- Open the Queue Timings report and set the date range to the last 30 days. Record queue time P75 and P90 for each runner label your repository uses.
- Open the Jobs report for the same range. Each row is a unique repository, workflow, and job name combination, with run count, success rate, duration P75 and P90, queue time P75 and P90, CPU P75 and P90, and memory P75 and P90.
- Subtract. If queue time P90 is a large share of total elapsed time, you have a capacity problem and no workflow edit will help. Go to the guide to reducing GitHub Actions queue times.
- If duration P90 dominates, sort the Jobs table by duration P90 and take the top three jobs. Read their CPU P90 and memory P90.
- High CPU and low duration variance points at cause 3, undersized runners. Low CPU with a long dependency step points at cause 2, cold caches. A long Docker build step with a multi architecture platform list points at cause 4.
- Export the table to CSV and keep it. The same export after the change is the before and after record.
CPU and memory columns require observability to be enabled, and jobs without telemetry show a dash. The time series chart plots one metric at P75 or P90 over time for the jobs on the current table page, which is how a regression that arrived with a specific merge becomes visible. Continuous tracking of these metrics is covered in GitHub Actions observability.
What the delay costs
Queue wait and execution time have different price tags. Queue wait costs engineer attention and merge latency and consumes no runner minutes. Execution time is billed per minute at the rate for the runner label.
Matched shapes against GitHub-hosted runners, with GitHub list prices from the GitHub Actions minute multipliers reference, checked on 2026-08-13:
| Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute |
|---|---|---|---|---|
| 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | ubuntu-latest on private repositories | $0.006 |
| 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 |
| 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 |
| 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 |
| 32 vCPU, 128 GB | warp-ubuntu-latest-x64-32x | $0.064 | 32-core Linux larger runner | $0.082 |
| 2 vCPU, 8 GB ARM64 | warp-ubuntu-latest-arm64-2x | $0.003 | ubuntu-24.04-arm on private repositories | $0.005 |
| 4 vCPU, 16 GB Windows | warp-windows-latest-x64-4x | $0.016 | 4-core Windows larger runner | $0.022 |
GitHub gives public repositories a 4 vCPU, 16 GB Linux shape at no charge, so the ubuntu-latest row above compares the private repository shape, which is the one paying teams run. The macOS comparison does not line up on shape: warp-macos-latest-arm64-6x carries 6 vCPU and 22 GB at $0.08 per minute, while the largest GitHub-hosted macOS ARM64 runner carries 5 vCPU and 14 GB at $0.102 per minute, checked on 2026-08-13.
The rate is one half of the bill and the minute count is the other. Per-size arithmetic for every label is on the pricing page.
Worked monthly model. Take 2,000 jobs per month at 10 minutes of execution each on a 4 vCPU Linux runner, which is 20,000 billed minutes.
warp-ubuntu-latest-x64-4x: 20,000 x $0.008 = $160.00 per month.- GitHub-hosted 4-core Linux larger runner: 20,000 x $0.012 = $240.00 per month.
- Difference on the same shape and the same minute count: $80.00 per month.
Now suppose the diagnosis says cause 3 and the job scales across cores. Moving to warp-ubuntu-latest-x64-8x at $0.016 per minute, with execution dropping from 10 minutes to 6, gives 2,000 x 6 = 12,000 minutes x $0.016 = $192.00 per month. That is $32.00 more than the 4 vCPU line for 4 minutes less wall clock on every job, which is the trade the per vCPU minute table predicts. Run the same arithmetic with your own duration percentiles from step 2 above before you commit to a size.
Full rates by runner type are on the WarpBuild pricing page.
Related Questions
How do I tell whether a GitHub Actions job is slow or only queued?
Read the Queue Timings report and the Jobs report side by side. Queue Timings gives you queue time P75 and P90 per runner label and stack, and Jobs gives you duration P75 and P90 for the same job. If queue time moves and duration stays flat, you have a capacity problem. If duration moves and queue time stays flat, you have a build problem. The guide to GitHub Actions queue times covers the capacity case.
Does a larger runner always make a GitHub Actions job finish sooner?
No. A larger runner helps only when the job saturates the cores it already has. WarpBuild Linux x64 runners are priced at $0.002 per vCPU minute at every size from 2 vCPU to 32 vCPU, so doubling cores doubles the per minute rate and pays for itself only if wall clock time drops by about half. Check peak CPU in the Jobs report before resizing, then follow the guide to speeding up GitHub Actions.
Why is my GitHub Actions cache not making builds faster?
The usual causes are a cache key that changes on every run, an eviction that happened before the next run started, or a Windows job, where WarpBuild caching is not supported. Compare cache hit steps in two consecutive runs of the same workflow, then read the guide to the GitHub Actions cache size limit.
What do larger GitHub Actions runners cost on WarpBuild?
Linux x64 runners run from $0.004 per minute at 2 vCPU and 8 GB to $0.064 per minute at 32 vCPU and 128 GB. Linux ARM64 runs from $0.003 to $0.048 per minute, Windows from $0.016 to $0.128 per minute, and macOS at $0.08 or $0.16 per minute. Full rates are on the WarpBuild pricing page.
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.