Cut CI queue time in GitHub Actions
GitHub-hosted runners cap how many jobs your account can run at once (20 on Free, more on paid plans), so past that ceiling jobs queue for a free slot no matter how your workflows are written. Measure the wait from a run being triggered to its job starting, then cut it by fixing over-restrictive concurrency groups or moving to runners with more available concurrency.
Do this
Jobs start within seconds of being triggered. Queue time is measured honestly, from the run's trigger (created_at) to the job's started_at, not the job's own created_at, which GitHub stamps late for gated jobs and which hides the real wait. Concurrency groups are scoped so unrelated PRs don't share one lane, and there's enough available job concurrency that a burst of open PRs doesn't leave jobs waiting for a free slot.12
# Scope concurrency to the ref so each PR gets its own lane
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: trueAvoid this
Every PR queues behind one shared lane, so developers wait before a single step runs.
concurrency:
group: ci # every PR shares one lane, so they queue behind each otherWhy it matters
Queue time is wait the developer feels but no code change can fix, the job hasn't even started. The biggest structural cause on GitHub-hosted runners is the account-level concurrency cap: GitHub limits how many jobs you can run at once (20 on the Free plan, more on paid tiers), so once your open PRs fan out past that ceiling, the extra jobs sit queued until a slot frees, no matter how well your own concurrency: groups are scoped. What you can fix in YAML is a group that serializes unrelated work, and measuring gated jobs honestly (from the run trigger, not the job's own timestamp, or you'll undercount by minutes). The cap itself you can't out-configure, which is why teams hitting it move to runners with more available concurrency; see StarSling vs GitHub Actions for how the two compare on wait-to-start.
When to use
Use it when
When your P90 wait-to-start is high (over ~60s for PR jobs), the job spends real time waiting before any step runs.
Be careful when
Don't confuse queue time with slow jobs. If the job starts promptly but runs long, the fix is caching/sharding/scoping, not queue capacity.
Verify on your repo
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix this practice in your own repo.
Inspect this repo's .github/workflows for `concurrency` groups that could be serializing unrelated work and making jobs wait before any step runs. Flag any group keyed on a global constant (for example `group: ci`) rather than the ref, and rescope it to include `${{ github.ref }}` so each PR gets its own lane instead of queuing behind the others. Keep in mind that queue time is the wait before a job starts, distinct from a slow-running job, so if jobs already start promptly the fix is elsewhere (caching, sharding), not concurrency. If the groups are already per-ref and jobs still wait before starting, the likely cause is GitHub's account-level concurrent-jobs cap, which is a capacity/provider decision, not a YAML change, so say so rather than inventing a workflow edit. Show me the diff and open a PR rather than applying it blindly.Prefer to check by hand?
Compute wait-to-start per job as run
created_at(the trigger) → jobstarted_at, not the job's owncreated_at, which is stamped late for gated jobs.Baseline by trigger type (PR runs queue differently than release/schedule runs) and flag P90 over ~60s for PR jobs.
Separate the causes: the account-level concurrent-jobs cap (you've saturated GitHub's per-plan limit, so jobs wait for a slot), runner-pool saturation (add capacity or larger runners), or an over-restrictive concurrency group (relax it or enable cancel-in-progress).
More best practices for GitHub Actions
Where to go next in the CI best-practices catalog.
- Trigger Scope
Cancel superseded runs with concurrency groups
Add a GitHub Actions
concurrencygroup scoped to the ref withcancel-in-progress: true, so a new push cancels the now-obsolete run instead of leaving both to occupy runners. - Parallelization
Shard tests across parallel jobs in GitHub Actions
Split one long test job into parallel shards with a matrix so the suite finishes in a fraction of the wall-clock time.
FAQ
My concurrency groups are already per-ref but jobs still queue. Why?
You're most likely hitting GitHub's account-level concurrent-jobs cap (20 on Free, 40 on Pro, 60 on Team, 500 on Enterprise). Once your in-flight jobs across every workflow exceed that ceiling, the rest wait for a free slot regardless of how your own concurrency: groups are scoped. That cap isn't a YAML setting, the fix is more available concurrency: a larger plan, or runners that don't serialize your jobs. See StarSling vs GitHub Actions.
Why is measuring queue time from the job's created_at wrong?
GitHub stamps a gated job's created_at only when its needs: dependency resolves, so started − job.created sees just that job's runner pickup and hides the upstream wait the developer also sat through. Measure from the run's trigger created_at to capture the full pre-start wait.
Does a faster runner reduce queue time?
Faster hardware shortens run time, not queue time, those are different axes. Queue time comes from waiting for a free runner or a concurrency slot. A provider with more available concurrency reduces the wait itself.
Sources
1GitHub Actions · concurrency (opens in new tab)
2GitHub Actions · usage limits (concurrent jobs by plan) (opens in new tab)
Last updated 2026-07-06
Cut the wait time before your jobs even start.
One line to install. Faster runs on day one, and agents that open reviewable PRs to keep your pipeline following practices like this one.