# Why is GitHub Actions slow? Four causes, each measured

> What makes GitHub Actions slow, measured on one job: queue time, the dependency cache, the Docker layer cache and runner size, with every timing.

Source: https://latchkey.dev/learn/speed/why-is-github-actions-slow  
Updated: 2026-09-20

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.

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

> Measured by job-a.sh on a Latchkey `latchkey-small` runner on 2026-09-20: 2 vCPU, 7,734 MB RAM, Node 20.20.2, npm 10.8.2. One pass per row.

## 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 |

> Measured by job-a.sh on the same run as the table above. The three warm builds shared one buildx builder, so only the first of them paid the cost of importing the cache; that is why the second is faster than the first despite doing more work.

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

## FAQ

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

## References

- [GitHub Docs: caching dependencies to speed up workflows (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [Docker Docs: optimize cache usage in builds (verified 2026-09-20)](https://docs.docker.com/build/cache/optimize/)
- [Docker Docs: cache storage backends (verified 2026-09-20)](https://docs.docker.com/build/cache/backends/)
- [GitHub Docs: GitHub-hosted runner specifications (verified 2026-09-20)](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
