How to Speed Up GitHub Actions Workflows
Slow CI costs you twice: developers wait, and you pay per minute. Most workflows can lose a third to a half of their run time without changing a line of product code. This guide shows how to find the slow parts first and then fix them in order of payoff.
Measure before you change anything
Open a recent run and look at the per-step timings, not the total. In the Actions UI, click a job and expand the steps; each shows its duration. Write down three numbers for your slowest workflow: time waiting in the queue, time in setup (checkout, toolchain, dependency install), and time in the actual build or test step.
You can also pull durations with the GitHub CLI:
gh run list --workflow ci.yml --limit 30 --json databaseId,createdAt,updatedAt,conclusion
gh run view <run-id> --json jobs --jq '.jobs[] | {name, started: .startedAt, done: .completedAt}'
The usual finding is that setup, not tests, is the biggest block. That is good news, because setup is the easiest part to cache.
Stop running work nobody needs
The cheapest minute is the one that never runs. Three changes remove whole runs.
Cancel superseded runs. When someone pushes twice to a PR, the first run is waste:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
The condition keeps runs on main from cancelling each other, so every merge still gets a full run.
Skip runs for irrelevant changes. A docs edit should not start the test suite:
on:
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
If a skipped workflow is a required check, the PR will wait forever for it. Either use a small always-running job that reports status, or filter inside the workflow with a path-detection step instead of at the trigger.
Set timeouts. The default job timeout is 360 minutes. A hung test can burn six hours of billed time. Set timeout-minutes: 15 (or twice your normal run time) on every job.
Cache what you download
Dependency installs and toolchain downloads repeat on every run. The setup-* actions have caching built in:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
setup-python, setup-java and setup-go have the same option. For everything else use actions/cache with a key built from a lockfile hash. Our guide to caching in GitHub Actions covers keys, restore-keys and the limits, and the cache key builder writes the YAML for you.
Do not cache node_modules directly when you can cache the package manager's download cache and run a clean install. A restored node_modules can hide a broken lockfile.
Run jobs in parallel
A workflow with one long job is a serial pipeline. Split independent work into separate jobs so they run side by side: lint, type-check, unit tests and build need nothing from each other.
jobs:
lint:
runs-on: ubuntu-latest
steps: [...]
test:
runs-on: ubuntu-latest
steps: [...]
build:
runs-on: ubuntu-latest
steps: [...]
Each job pays its own setup cost, so splitting only pays off when setup is cached and short. For a single long test job, shard it instead: see test sharding and parallel tests and the test sharding planner.
Do less in each step
- Shallow checkout.
actions/checkoutfetches one commit by default (fetch-depth: 1). Do not setfetch-depth: 0unless a step needs history, such as affected-package detection. - Build only what changed. In a monorepo, run only affected projects. See monorepo CI with Nx or Turborepo.
- Reuse build output. Build once, upload with
actions/upload-artifact, and download in later jobs instead of rebuilding in each. - Docker. Layer caching often turns a five-minute image build into thirty seconds. See Docker layer caching in CI.
- Trim the matrix. Every extra OS or version multiplies cost. See matrix builds done right.
Use a bigger or faster runner
The default ubuntu-latest runner for private repositories has 2 vCPU and 7 GB of RAM. If your build or tests use several cores (compilers, bundlers, pytest -n auto, go test), more cores help directly. GitHub's 4-core larger runner costs $0.012 per minute against $0.006 for 2-core, per its runner pricing page. A job that gets more than twice as fast on 4 cores is cheaper as well as faster.
The runner specs tool lists every size and label, and the cost calculator shows what a size change does to your monthly bill. Test it on one workflow before you switch the lot: single-threaded steps gain nothing from extra cores.
Check queue time
If jobs wait before starting, no YAML change helps. Causes are plan concurrency limits (the number of jobs you can run at once depends on your GitHub plan), or too many tiny jobs. Fewer, larger jobs reduce queueing. Self-hosted or third-party runners can remove the limit.
A practical order of work
- Add
concurrencyandtimeout-minutes(ten minutes, no risk). - Turn on dependency caching.
- Split lint, test and build into parallel jobs.
- Shard the slowest test job.
- Try a bigger runner on the slowest job and compare cost per run, not just time.
FAQ
What is a good target for CI time?
Many teams aim for pull request feedback in under 10 minutes. The DORA research links short feedback loops to better delivery performance. Pick a number for your slowest required check and track it weekly.
Does a bigger runner always make CI faster?
No. It helps only for parallel work. Network downloads, single-threaded scripts and waiting on external services do not speed up with cores.
Will caching make my builds wrong?
Not if the key includes everything that affects the contents, usually the lockfile hash and the OS. A stale cache is almost always a key that is too broad.
Am I billed for queue time?
No. GitHub bills only the time a job runs, but queue time still delays developers.
Measure on your own jobs
If you want to see what a faster machine does to your real workflows, compiler.dev runs the same jobs on faster runners and has a comparison mode that shows run time and cost side by side. In our benchmarks it was faster on 15 of 19 stacks, by 1.1 to 3 times, against GitHub's 2-core runner. Your result depends on how parallel your jobs are, so measure rather than assume.
Made by compiler.dev. Free tools · Pricing