GitHub Actions slow to start: queue time, setup time and the job
"GitHub Actions slow to start" is two unrelated problems wearing one symptom, and the fixes have nothing in common: either the job waited for a runner, which no change to your YAML touches, or it spent the time setting itself up, which is almost entirely configuration. Measured on a runner, a shallow checkout cost 0.83 seconds and a toolchain already in the tool cache cost 9 milliseconds, while one apt-get update cost 23.9.

Every job pays a fixed cost before your build starts: waiting for a runner, checking out, installing a toolchain, restoring dependencies. On a five-minute pipeline that overhead is routinely half the wall clock, and a matrix pays all of it again per leg.
The mistake almost everyone makes is treating it as one number. Queue time and setup time both look like "slow to start" in the Actions UI and they have completely different remedies, so the first useful thing you can do is split them apart.
Split the number before you optimize it
Three intervals, and you want all three. The gap between the run being created and a runner picking it up is queue time. The gap between the first step starting and your own build command starting is setup. Everything after that is the job. Only the last two respond to anything you write in the workflow file.
The GitHub CLI gives you the first interval across recent runs in one command, and a timestamp echo inside the job gives you the second. Do this once, on your slowest workflow, before you spend an afternoon on cache keys that may be irrelevant.
# queue time and run duration, most recent 20 runs
gh run list --limit 20 --json databaseId,createdAt,startedAt,updatedAt \
--jq '.[] | "queued: \((( .startedAt|fromdate) - (.createdAt|fromdate)))s run: \((( .updatedAt|fromdate) - (.startedAt|fromdate)))s"'What setup actually costs, measured
We ran a job that does nothing except time the fixed costs, on a Latchkey latchkey-small runner. It cloned a real repository shallow and then with full history, performed the two halves of what a setup action does, and installed a system package. One pass per row.
The result reorders the usual advice. Checkout is cheap and shallow checkout saves about a tenth of a second on a 1,901-commit repository. A toolchain already present costs nine milliseconds. A toolchain that has to be fetched costs 2.3 seconds, almost all of it in the extract rather than the download. And a single apt-get update, the step people add without thinking, cost 23.9 seconds, more than every other row on the page put together.
| Fixed cost, control held: same runner, same job | Duration | What it produced |
|---|---|---|
apt-get update | 23.9 s | A refreshed package index |
| Extract a toolchain into the tool cache | 2.16 s | 204 MB unpacked |
git clone with full history | 0.95 s | 17 MB, 1,901 commits |
git clone --depth 1, the checkout default | 0.83 s | 5 MB, 1 commit |
| Download the toolchain tarball | 0.16 s | 30,332 KB |
apt-get install of a package already present | 0.41 s | nothing |
| Put a tool-cache toolchain on PATH, the cache-hit case | 0.009 s | nothing to download |
The tool cache is why setup actions look free
A setup-* action does one of two completely different things depending on what is already on the runner image. If the version you asked for is in the tool cache, at /opt/hostedtoolcache, the action adds a directory to PATH and finishes. If it is not, the action downloads a distribution and unpacks it, and that is a real cost you pay on every job in the matrix.
Our runner image held PyPy, Ruby, Go and Node in that directory. The difference between the two paths was 2.32 seconds, 0.16 to download and 2.16 to extract, against 9 milliseconds: about 260 times, for exactly the same end state. So the highest-return thing you can do to a setup-* step is not to cache anything, it is to ask for a version the image already has, and to pin it so a patch release does not silently move you onto the slow path.
# a version already in the tool cache: a PATH change
- uses: actions/setup-node@v7
with:
node-version: '20'
cache: npm
# an exact version the image may not carry: a download and an extract, every job
- uses: actions/setup-node@v7
with:
node-version: '20.11.1'Queue time is a supply problem, not a YAML problem
If the run sits on "Waiting for a runner to pick up this job", nothing in the workflow file is the cause and nothing in it is the cure. 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 every label in runs-on.
The only lever inside the repository is not queueing: a concurrency group that cancels a superseded run rather than letting it wait in line behind work nobody needs any more. Everything else is capacity, which means more runners, a different pool, or a provider whose pickup you can actually read.
Ours, for what it is worth as a comparison point: across the six ad-hoc jobs behind these pages, the Latchkey CLI recorded 68.2 to 70.4 seconds between submitting a job and it starting. That is the CLI path, which provisions a fresh machine per job and is not how an Actions job is picked up. Latchkey documents the Actions figure separately as "Warm: a few seconds. Cold: about 10 seconds, registered just-in-time", and we are quoting that rather than implying our CLI number is it.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueCut the checkout only where it is actually deep
The default is already shallow, and on our 1,901-commit repository the difference between a shallow clone and a full one was 0.83 seconds against 0.95. That is not where your time is going, and the advice to obsess over it is stale.
It becomes real on a repository with a long history or large binaries in it, and the thing to look for is a fetch-depth: 0 that somebody copied into every job to make one diff work. Deepen the job that genuinely needs history, and leave the rest at the default.
# only the job that diffs against a base needs history
- uses: actions/checkout@v7
with:
fetch-depth: 0
# everywhere else, the default, plus skipping submodules you do not build
- uses: actions/checkout@v7
with:
submodules: falseA matrix pays the whole fixed cost per leg
This is where a small setup number becomes a large one. Every entry in a matrix repeats the queue wait, the checkout, the toolchain setup and the dependency restore in full. Ten shards with ninety seconds of setup spend fifteen minutes on setup alone, and the wall clock only improves if the work being split is larger than that.
Before widening a matrix, add the numbers you just measured and compare them with the time the split is meant to save. Fewer, longer shards are frequently both faster in wall clock and cheaper, which is the rare change that needs no trade-off argument at all.
# 10 shards x 90s of setup is 15 minutes of setup for one suite
strategy:
matrix:
shard: [1, 2, 3, 4]What we ran, so you can disagree with it
One script, job-f.sh, run once on a Latchkey latchkey-small runner on 20 September 2026, committed under content/repro/timings/github-actions-cold-start-and-setup-time/ with its unedited log and a status file carrying the job id, the runner size, the exit code and the timestamps the pickup numbers come from. The pickup range quoted above is read from the six status files behind this batch of pages, not from one.
The caveats. One pass per row, so a gap under about half a second is not a real difference, which covers both checkout rows and the package-install row. The runner sits on a fast network, which makes the download row unusually cheap and pushes the extract share of a toolchain install to the top of its realistic range; on a slower link the two halves would look more even. And the apt-get install row installed nothing, because the package was already on the image, so it measures the overhead of the command and not an install.
Frequently asked questions
Why does my job sit on "Waiting for a runner to pick up this job"?
runs-on, which a typo in one label is enough to cause. Nothing inside the workflow file reduces this except not queueing, so cancel superseded runs with a concurrency group and check the labels first.How do I reduce GitHub Actions setup time?
apt-get update, which cost 23.9 seconds on its own, by moving system packages into a prebuilt image. Ask setup actions for a version the runner image already carries, which was 9 milliseconds against 2.3 seconds. Use the cache: option on the setup action rather than a separate step. Leave checkout at its default depth.