Skip to content
Latchkey LogoLatchkey home

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.

Bars: npm ci 4.2 s cold against 2.4 s cached, Docker build 19.5 s cold against 5.8 s warm
The same job on a latchkey-small runner on 2026-09-20, from job-a.sh. The dependency cache returned 1.8 seconds; the layer cache returned 13.7 seconds on the same commit.

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.

.github/workflows/ci.yml
- 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 || true

Dependencies: 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 packagesDuration
No dependency cache at all4.2 s
~/.npm restored, first pass2.4 s
~/.npm restored, second pass2.3 s
Size of the restored directory20 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 imageDuration
Cold, no layer cache19.5 s
Warm cache, nothing changed7.3 s
Warm cache, application source changed5.8 s
Warm cache, lockfile changed11.5 s
Size of the exported layer cache106 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.

.github/workflows/ci.yml
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

The 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.

.github/workflows/ci.yml
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?
The job is waiting for a runner, not running slowly. On GitHub-hosted runners that is contention for the pool your plan gives you; on self-hosted runners it usually means no idle runner matches the labels in 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?
In the order the measurements above support: do not run work nothing changed, restore a Docker layer cache if you build images, restore a dependency cache, then consider a bigger runner. Measure your own run before picking, though, because a pipeline with no container build has a completely different profile.
How can I speed up GitHub Actions workflows for Node.js without sacrificing reliability?
Cache ~/.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.
How to improve GitHub Actions performance when running complex workflows?
Split the run into jobs that can start independently, so the slowest one sets the wall clock rather than the sum, then attack that job. On a complex workflow the total is almost never the problem; one long pole is, usually an image build with no layer cache or an unsharded test suite.

Related guides

References

Both caches have to outlive the runner. On Latchkey the Docker layer cache persists between jobs. Start free → 30-day trial · No credit card