Why is GitHub Actions slow? Four causes, each measured
Four things make GitHub Actions slow: waiting for a runner to pick the job up, installing dependencies a cache should have restored, rebuilding Docker layers a cache should have supplied, and running work that nothing had changed. We measured the middle two on the same small service on a Latchkey runner, and the Docker layer cache was worth more than seven times what the dependency cache was worth.

Every slow pipeline is slow for a reason you can name, and only four of them are worth checking first. Either the job waited before it started, or it installed things it already had, or it rebuilt an image it had already built, or it ran at all when nothing it tests had changed.
The order you attack them in matters, because the fixes are not worth the same. On the service we measured below, restoring the dependency cache saved 1.8 seconds and restoring the Docker layer cache saved 13.7 seconds on the same commit. Spend the afternoon on the first one and you will have spent it badly.
Read the timeline before you change any YAML
Open your slowest run and look at the per-step durations rather than the total. The shape of that list already tells you which of the four you have. A long gap before the first step is queue time and no amount of caching touches it. A long install step that ends in success is a cache that missed. A build step where every layer re-runs is a layer cache that was never restored.
Two facts the log does not hand you are worth printing yourself: whether the cache step reported an exact hit, and how big the thing it restored is. Both are one line each, and they turn the next slow run into an answer rather than a guess.
- uses: actions/cache@v6
id: deps
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: What the cache did
run: |
# true = the exact key hit; false = a restore-key hit; empty = nothing matched
echo "cache-hit: '${{ steps.deps.outputs.cache-hit }}'"
du -sh ~/.npm || trueDependencies: what the cache is actually worth
We ran the same install three ways on a Latchkey latchkey-small runner: once with the npm cache emptied, then twice with it warm, on a lockfile with 414 packages. Cold took 4.2 seconds, warm took 2.4 and then 2.3. The restored directory was 20 MB, which is what an actions/cache entry for this project would hold.
That is a real saving and a small one, and the ratio transfers where the seconds do not. A 414-package tree is modest, and the gap grows with the tree and with anything that compiles during install, which is why the same change is worth minutes on a Rust or Gradle project and seconds here.
The same npm ci, 414 packages | Duration |
|---|---|
| No dependency cache at all | 4.2 s |
~/.npm restored, first pass | 2.4 s |
~/.npm restored, second pass | 2.3 s |
| Size of the restored directory | 20 MB |
Docker layers: the one that usually dominates
The same script then built a small two-stage Node image four ways. Cold, with nothing to restore, it took 19.5 seconds. With the exported layer cache available and only the application source changed, the realistic pull request case, it took 5.8 seconds. Change the lockfile instead and it took 11.5 seconds, because the dependency layer legitimately had to be rebuilt.
This is the number that should decide your afternoon. A fresh runner starts with no image layers, so every FROM and every RUN re-executes unless you hand the builder a cache from somewhere. Your laptop hides this completely: the daemon there keeps layers between builds, so the build that takes four seconds locally is doing nothing like the work the runner is doing.
| The same two-stage image | Duration |
|---|---|
| Cold, no layer cache | 19.5 s |
| Warm cache, nothing changed | 7.3 s |
| Warm cache, application source changed | 5.8 s |
| Warm cache, lockfile changed | 11.5 s |
| Size of the exported layer cache | 106 MB |
Queue time is not build time
If the run sits on "Waiting for a runner to pick up this job" the workflow is not slow, the supply of runners is. Caching, sharding and layer ordering all change nothing here. What changes it is concurrency you actually have: a different runner pool, fewer jobs contending for the same one, or a concurrency group that cancels the superseded run instead of queueing behind it.
Measure it before you argue about it. The gap between the run being created and the first step starting is queue time, and no change inside the YAML recovers any of it.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueThe cheapest second is the one you do not spend
Path filters are the highest-return change on this list precisely because they are not an optimization: a job that does not run costs nothing and cannot flake. A documentation commit does not need your container image built, and a change under one package of a monorepo does not need the other six tested.
The failure mode to watch for is a required check that never reports because it was filtered out. Use a job that always runs and decides, rather than a filter that removes the check from the run entirely, when the check is required for merge.
on:
pull_request:
paths:
- 'src/**'
- 'package-lock.json'
- 'Dockerfile'Runner size is the last lever, not the first
A bigger runner buys you a linear improvement on the work that remains, and it buys nothing on the work you should have skipped. We ran one cold image build, the four-stage one from our backend comparison rather than the two-stage image timed above, on a latchkey-medium runner with 4 vCPU instead of 2: it went from 12.2 seconds to 9.6, a 21 percent improvement, while the same commit with a warm registry layer cache took 8.8 seconds on the small runner.
That is the whole argument for doing caching first. The cache beat the hardware here, and it keeps beating it, because the saving repeats and the higher rate does not go away.
What we ran, so you can disagree with it
Three scripts, each run once on a Latchkey runner on 20 September 2026, all committed under content/repro/timings/ with the output they produced. why-is-github-actions-slow/job-a.sh built the npm and Docker comparisons on a latchkey-small runner, docker-layer-caching-in-github-actions/job-b.sh compared the buildx cache backends at the same size, and speed-up-docker-builds-in-github-actions/job-d.sh repeated the cold build on a latchkey-medium runner. Each has a .log of that run's output and a .status.json naming the job id, the runner size and the exit code.
Two things about those runs belong on the page rather than in a footnote. job-b.sh ran twice: the first attempt aborted at its first docker buildx rm because the runner executes the command under set -e and no builder existed yet, and the committed script differs from it by one added line, set +e. Nothing carried over, because that attempt never reached a timing. In job-a.sh the three warm Docker builds shared one buildx builder, so only the first of them paid the cache import.
The caveats are the ones any single pass carries. One run per row means the seconds hold runner-level noise, and a difference under about half a second is not real. The project is deliberately small, so the absolute savings are small and the ratios are the transferable part. The registry measurement used a registry on the runner itself, so it excludes the network an off-host registry would add.
Frequently asked questions
Why is my GitHub Actions workflow stuck in the "queued" state?
runs-on. Nothing in your YAML reduces queue time except not queueing: cancel superseded runs with a concurrency group, and split jobs that do not need to wait for each other.What are the best practices to speed up GitHub Actions workflows?
How can I speed up GitHub Actions workflows for Node.js without sacrificing reliability?
~/.npm rather than node_modules, and keep running npm ci after the restore. The package manager directory is platform-neutral and the install reconciles it against the lockfile, so a stale cache costs you a few seconds rather than an error that only appears in CI. Restoring a prebuilt node_modules is the version of this that produces failures nobody can reproduce locally.