Skip to content
Latchkey

How to Cut GitHub Actions Cold Start and Setup Time

On a short pipeline, more time frequently goes to getting ready to work than to the work. Measure the gap between queued and the first real step before optimising anything downstream.

Every job pays a fixed cost before your build starts: waiting for a runner, booting it, checking out, installing a toolchain, and restoring dependencies. On a five-minute job that overhead is routinely half the wall clock, and it is paid again by every job in a matrix.

It is also the cheapest thing to fix, because unlike your build it is almost entirely configuration.

Measure the overhead

Terminal
# queue time and total duration for recent runs
gh run list --limit 20 --json databaseId,createdAt,startedAt,updatedAt \
  --jq '.[] | "queued: \((( .startedAt|fromdate) - (.createdAt|fromdate)))s  total: \((( .updatedAt|fromdate) - (.startedAt|fromdate)))s"'

# then inside a job, time the setup itself
- run: echo "setup finished at $(date +%s)"

Cut checkout

.github/workflows/ci.yml
# default is already shallow, but make it explicit and skip submodules
- uses: actions/checkout@v4
  with:
    fetch-depth: 1
    submodules: false

# only deepen where you actually need history
- uses: actions/checkout@v4
  with:
    fetch-depth: 0   # required for diffs, tags, git describe

Cut toolchain and dependency setup

  • Use the cache: option on setup actions rather than a separate actions/cache step. It is keyed correctly for that ecosystem by default.
  • Key caches to the lockfile hash so an unchanged lockfile is an exact hit.
  • Install browsers, databases, and system packages only in the jobs that use them.
  • Prefer a prebuilt container image over installing packages at job start when the install is more than a few seconds.
  • Run the cheapest gating checks first so an expensive job never starts on code that cannot pass.

Stop paying setup N times in a matrix

A matrix repeats the full fixed cost per entry. Before widening one, check that the overhead is not larger than the work being parallelised.

.github/workflows/ci.yml
# 10 shards x 90s setup = 15 minutes of setup for one test suite.
# Fewer, longer shards are often faster in wall clock AND cheaper.
strategy:
  matrix:
    shard: [1, 2, 3, 4]   # not [1..10] unless the tests justify it

Measure before you optimise

Pipeline optimisation usually targets the step people assume is slow. Get the real per-step timings first, because the answer is frequently dependency install or a cold cache rather than the build itself.

Terminal
# per-job timings for the last 20 runs
gh run list --limit 20 --json databaseId,conclusion,createdAt,updatedAt \
  --jq '.[] | "\(.conclusion)\t\(.createdAt)\t\(.updatedAt)"'

# per-step timing inside one run
gh run view <run-id> --log | grep -E "^\S+\s+.*Run |##\[group\]" | head -40

Frequently asked questions

Why do my GitHub Actions jobs take so long to start?
Two separate causes: queue time waiting for an available runner, and setup time for checkout, toolchain, and dependency install. Measure them separately, because capacity fixes the first and configuration fixes the second.
How do I reduce GitHub Actions setup time?
Use the cache: option on setup actions keyed to your lockfile, keep fetch-depth: 1 unless a job genuinely needs history, install browsers and services only where used, and avoid widening a matrix past the point where setup dominates.
Does a bigger runner start faster?
Not necessarily. Runner size affects execution speed, not queue time or checkout. If your overhead is queue time, a larger runner can be slower to allocate, not faster.
Is a matrix always faster?
No. Each matrix entry repeats the full setup cost. Ten shards with 90 seconds of setup spend 15 minutes on setup alone, which can exceed the time saved by splitting the work.

Related guides

References

Run this faster and cheaper on Latchkey managed runners - self-healing included. Start free → 30-day trial · No credit card