Best practice · Runner & Queue

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

Per-ref concurrency, so PRs don't serialize
# 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 }}
  # cancel superseded PR runs; let main / release runs finish
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Avoid this

Every PR queues behind one shared lane, so developers wait before a single step runs.

One global lane: everyone queues
concurrency:
  group: ci        # every PR shares one lane, so they queue behind each other

Why 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, 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.

Prompt for your coding agent
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/concepts/workflows-and-actions/concurrency, 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?

  1. Compute wait-to-start per job as run created_at (the trigger) → job started_at, not the job's own created_at, which is stamped late for gated jobs.

  2. Baseline by trigger type (PR runs queue differently than release/schedule runs) and flag P90 over ~60s for PR jobs.

  3. 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).

Automate this with StarSling.

Rather than audit this by hand, install the StarSling GitHub App: it opens a pull request to move your workflows onto StarSling runners, then its agents keep reading your jobs, run logs, and machine telemetry and open pull requests that fix what this page covers, along with caching, parallelization, and test sharding across the rest of your pipeline. Every change arrives as a PR you review and merge.

More best practices for GitHub Actions

Where to go next in the CI best-practices catalog.

All CI best practices

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 (opens in new tab)

2GitHub Actions · usage limits (concurrent jobs by plan) (opens in new tab)

Last updated 2026-07-15

Get started

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.