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.
How StarSling worksAI agents open the PR
StarSling agents inspect your workflow, apply this optimization, and open a reviewable PR automatically.
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 WORKFLOW and the ref, so each PR gets its own lane
# and this workflow never cancels a different workflow's runs.
#
# github.workflow is not optional here. Concurrency group names are repository-wide:
# GitHub cancels "any previously in-progress or pending job ... regardless of the
# workflow" that shares a group name. Leave it out and two workflows on the same ref
# cancel each other, which is a worse failure than the queueing you came here to fix.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
queue: single
# cancel superseded PR runs; let main / release runs finish
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# If this workflow must keep a backlog instead of dropping pending runs, use this
# block INSTEAD. queue: max allows up to 100 pending runs, but GitHub rejects it
# when cancel-in-progress is true.
# concurrency:
# group: ${{ github.workflow }}-${{ github.ref }}
# queue: max
# cancel-in-progress: falseAvoid 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 other
queue: single # default: one pending run, then newer runs replace itWhy 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 (around 20 on the Free plan as of this writing, more on paid tiers; the exact caps change, so treat the numbers as a snapshot and check GitHub's linked limits doc), 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. By default that group uses queue: single: one run or job is active, one pending run is retained, and the next arrival replaces the pending one. queue: max keeps a backlog of up to 100 pending runs instead, but GitHub rejects it if cancel-in-progress: true is also set. Measuring gated jobs honestly (from the run trigger, not the job's own timestamp) keeps those modes from hiding minutes of wait. The account 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 `${{ github.workflow }}-${{ github.ref }}` so each PR gets its own lane instead of queuing behind the others. Include `${{ github.workflow }}`: concurrency group names are repository-wide, so a group keyed on the ref alone will cancel in-progress runs of OTHER workflows on that same ref. 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.
Ground these changes in the upstream docs before you edit: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax, https://docs.github.com/en/actions/reference/limits. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.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.
Search workflows for
queue:insideconcurrency:and confirm the mode is deliberate:queue: singlekeeps one pending run, whilequeue: maxkeeps up to 100 and cannot be paired withcancel-in-progress: true.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).
Go further
One fix, all of them, or forever.
You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.
Fix this one thing
Copy the prompt above
Hand OPT43 to your coding agent and fix it in your repo today.
Fix everything, once
Install the ci-score skill
One prompt grades your whole workflow config against all 11 CI Score checks, this one included, and hands your agent a ranked fix for every gap it finds. Open source, runs locally. It grades configuration, not speed.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
More best practices for GitHub Actions
Where to go next in the CI best-practices catalog.
- Trigger Scope
Cancel superseded runs with concurrency and cancel-in-progress
Add a GitHub Actions
concurrencygroup scoped to the ref and turn oncancel-in-progressfor PR runs (it defaults tofalse), 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
Speed up GitHub Actions tests by splitting one long test job into parallel shards with a matrix, so the suite finishes in a fraction of the wall-clock time.
- Runner & Queue
Right-size GitHub Actions runners without slowing CI
Right-size a GitHub Actions job by comparing the same commit on the current and smaller runner, then keep the smaller size only when pass rate and wall-clock remain equivalent.
- Runner & Queue
Bound GitHub Actions runner jobs with timeout-minutes
Set
timeout-minuteson every GitHub Actions job that executes on a runner, plus hang-prone steps, so a stuck run is cancelled in minutes instead of occupying a runner for up to the 360-minute default.
More ways to improve GitHub Actions
If you do not know why a run is slow yet, start with the diagnostic guide. Let an agent find which of these applies: /ci-speedup. If your workflows are already optimized but still slow, see our guide to fast GitHub Actions and GitHub Actions runner alternatives. Building containers in CI? The Docker workflow guide covers layer caching end to end. Want to see how your configuration measures up before changing anything? CI Score grades workflow config against a pass/fail rubric of these practices. It is not a speed measurement. To find exploitable workflow paths before attackers do, ci-secure checks the ten critical GitHub Actions attack vectors and lets you choose which fixes to apply.
- GitHub Actions too slow
- Fast GitHub Actions
- GitHub Actions alternatives
- GitHub Actions pricing
- Self-hosted GitHub Actions runners
- Docker CI on GitHub Actions
- Install the free /ci-score skill to improve your GitHub Actions setup
- Install the /ci-secure skill to close critical attack vectors in GitHub Actions
- Browse GitHub Actions agent skills
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. As of this page's last update GitHub lists standard GitHub-hosted runner concurrency as plan-dependent (on the order of 20 concurrent jobs on Free, more on paid tiers), but these limits change, so check GitHub's official Actions limits page for current values. 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.
How many runs can queue in one concurrency group?
One, by default. A concurrency group runs one job or workflow at a time; a second arrival waits as pending, and a third cancels that pending one rather than lining up behind it. So a group is a depth-1 queue, not a buffer: GitHub's queue: max setting opts into up to 100 pending runs, and queue: single (the default) is the cancel-the-pending-one behavior. Note that queue: max and cancel-in-progress: true cannot be combined: GitHub rejects the workflow with a validation error, which matters here because this page tells you to set cancel-in-progress on PRs. That is separate from the account-level concurrent-jobs cap above, which is about how many jobs your plan lets you run at once across every workflow. For what cancel-in-progress does to the run already going, see cancel superseded runs.
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 syntax (opens in new tab)
2GitHub Actions · usage limits (concurrent jobs by plan) (opens in new tab)
Last updated 2026-08-19