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.

RankCauseSymptom you seeWhere to confirm it
1Queue wait before the job startsJob sits in "Queued" with no logs, step timings look normalQueue Timings report, queue time P75 and P90 per runner label
2Cold cacheDependency install and compile steps take the same time on every runCache hit or miss output in the job log, cache billing table
3Undersized runnerOne long step pinned at high CPU or memory, everything else fastJobs report duration percentiles plus CPU and memory percentiles
4Emulated buildA multi architecture Docker build step dominates the jobBuild 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 test

Three 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:

ItemRateUnit
Cache storage$0.20per GB month
Cache write or restore$0.0001per operation
Snapshot restore$0.04per job
Snapshot storage$0.025per snapshot hour
Networking (Tailscale)$0per 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.sh

At $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:

MetricUnder provisioned thresholdAlert label
Max sustained CPU80 percent or higherHigh CPU Usage
Max memory utilization80 percent or higherHigh Memory Usage
Max filesystem utilization80 percent or higherHigh Filesystem Usage
Max disk I/O80 percent or higher of supported throughputHigh 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 labelvCPURAMStoragePer minutePer vCPU minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004$0.002
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008$0.002
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016$0.002
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032$0.002
warp-ubuntu-latest-x64-32x32128 GB150GB 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.

  1. 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.
  2. 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.
  3. 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.
  4. If duration P90 dominates, sort the Jobs table by duration P90 and take the top three jobs. Read their CPU P90 and memory P90.
  5. 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.
  6. 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:

ShapeWarpBuild labelWarpBuild per minuteGitHub-hosted equivalentGitHub per minute
2 vCPU, 8 GBwarp-ubuntu-latest-x64-2x$0.004ubuntu-latest on private repositories$0.006
4 vCPU, 16 GBwarp-ubuntu-latest-x64-4x$0.0084-core Linux larger runner$0.012
8 vCPU, 32 GBwarp-ubuntu-latest-x64-8x$0.0168-core Linux larger runner$0.022
16 vCPU, 64 GBwarp-ubuntu-latest-x64-16x$0.03216-core Linux larger runner$0.042
32 vCPU, 128 GBwarp-ubuntu-latest-x64-32x$0.06432-core Linux larger runner$0.082
2 vCPU, 8 GB ARM64warp-ubuntu-latest-arm64-2x$0.003ubuntu-24.04-arm on private repositories$0.005
4 vCPU, 16 GB Windowswarp-windows-latest-x64-4x$0.0164-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.

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.