---
title: "Reduce GitHub Actions queue time: capacity and concurrency"
description: "Jobs waiting minutes before a runner picks them up? How to diagnose and cut GitHub Actions queue time: concurrency scope, runner capacity, and pricing."
url: https://starsling.dev/best-practices/github-actions/cut-queue-time
canonicalUrl: https://starsling.dev/best-practices/github-actions/cut-queue-time
---

# Cut CI queue time in GitHub Actions

Best practice: Runner & Queue. Last updated: 2026-07-15

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.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [Why it matters](#why-it-matters)
- [When to use](#when-to-use)
- [Verify on your repo](#verify)
- [Sources](#sources)

<a id="do-this"></a>

## 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.

_Per-ref concurrency, so PRs don't serialize_

```yaml
# 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' }}
```

<a id="avoid-this"></a>

## Avoid this

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

_One global lane: everyone queues_

```yaml
concurrency:
  group: ci        # every PR shares one lane, so they queue behind each other
```

<a id="why-it-matters"></a>

## 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](/compare/github-actions) for how the two compare on wait-to-start.

<a id="when-to-use"></a>

## 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.

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent 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/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:

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

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Cancel superseded runs with concurrency groups](https://starsling.dev/best-practices/github-actions/cancel-superseded-runs)
- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

<a id="faq"></a>

## 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](https://docs.github.com/en/actions/reference/limits) 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](/compare/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](/best-practices/github-actions/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.

<a id="sources"></a>

## Sources

1. [GitHub Actions - concurrency](https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency)
2. [GitHub Actions - usage limits (concurrent jobs by plan)](https://docs.github.com/en/actions/reference/limits)

<a id="get-started"></a>

## Get started

Cut the wait time before your jobs even start.
