Why Do My GitHub Actions Jobs Queue Behind Each Other?
Three things serialize jobs: a concurrency group holding runs in one lane, an account-level parallel job ceiling, and a runner pool with no free capacity.
Answer
Three separate mechanisms serialize GitHub Actions jobs, and from the run list they look alike: a concurrency group that holds every matching run in one lane, an account-level ceiling on parallel jobs, and a runner pool with no free capacity behind your runs-on labels. Each one leaves a different visible signal, so identify the signal before editing any workflow file, because the fix for one does nothing for the other two.
| Signal you can see | Cause | Where to confirm it |
|---|---|---|
| Run header shows a Waiting banner naming a group, and no jobs have started | A concurrency group | The workflow run page |
| A queued run flips to Canceled the moment a newer commit lands on the same ref | A concurrency group under the default queue: single | The workflow run page |
| Running job count plateaus at 20, 40, 60, or 500 and then advances in waves of that size | Account-level ceiling on GitHub-hosted runners | GitHub usage limits |
| macOS jobs wait while Linux jobs on the same account start immediately | Account-level ceiling, macOS sub-limit | GitHub usage limits |
| Jobs on one runner label wait while jobs on other labels start on time | Runner pool capacity for that label | Queue Timings report |
| Jobs on a label never start at all and produce no logs | Label mismatch or runner group access | Common issues |
The last row is the one people misread most often. A job whose labels match zero online runners waits forever, which feels like a capacity shortage and is a routing problem.
Detail
1. A concurrency group holding runs in one lane
The concurrency key creates a named lane that allows one workflow run or job at a time, documented in the workflow syntax reference, checked on 2026-08-13. Group names resolve to strings, they are case insensitive, and membership is scoped to the repository rather than to one workflow file. Two workflow files whose group expressions produce the same string share a single lane.
A literal group name is the usual accident:
name: deploy
on:
push:
branches: ["**"]
concurrency:
group: deploy
cancel-in-progress: false
jobs:
ship:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.shEvery push in the repository resolves to deploy, so a push on a feature branch parks behind a release running on main. Widening the key restores parallelism while keeping the property you wanted, which is one deploy at a time per target:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}Under the default queue: single the group holds one running item and one pending item, and a third arrival cancels the pending one. Setting queue: max lets up to 100 arrivals wait. The concurrency group definition covers the settings and the states they produce. A group only holds work back or cancels it, and it always labels the run header while doing so, so a run page without a Waiting banner points you at one of the other two causes.
2. An account-level parallel job ceiling
Jobs on standard GitHub-hosted runners draw from shared pools with a fixed ceiling per account: 20 concurrent jobs on Free, 40 on Pro, 60 on Team, and 500 on Enterprise, with macOS sub-ceilings of 5 on the first three plans and 50 on Enterprise. Those figures come from GitHub's usage limits reference, checked on 2026-08-13, and a matrix caps out at 256 jobs per workflow run from the same page.
The ceiling is account wide. One repository fanning out a wide matrix pushes every other repository on the account into the same wave pattern, which is why the queue often starts in a repository nobody on your team touched. The guide to GitHub Actions concurrency limits walks the full symptom to mechanism mapping and the configuration that clears each one.
3. A runner pool with no free capacity
Jobs targeting self-hosted or managed labels sit outside the account ceiling and queue on pool capacity instead. For a static fleet the ceiling is the count of registered machines. For an autoscaled fleet such as an ARC scale set it is the maximum runner setting. Either way, once every matching machine is busy, job N+1 waits for machine 1 to finish, which is the wave pattern people describe as jobs queueing behind each other.
How to read wait time per runner label
Queue wait per label is the measurement that separates cause 3 from causes 1 and 2, and it is the number to watch after any change. Per the reports documentation:
- Queue Timings has one row per runner label and stack, carrying run count, queue time P75, and queue time P90, plus a daily bar chart of average queue time and job count.
- Jobs has one row per repository, workflow, and job name, with duration P75 and P90 next to queue time P75 and P90, so a job that got slower and a job that started later read differently.
- Both tabs export to CSV with the current filters applied, so a before and after comparison is a spreadsheet rather than a screenshot.
Sort Queue Timings by P90 and filter to one label at a time. A high P90 with a normal P75 on one label is a burst overrunning capacity at specific hours. A high P75 and P90 together on one label is a steady shortage. Near-zero percentiles on every label while pull requests still feel slow means the queue is fine and job duration is the problem, which the guide to GitHub Actions queue times takes apart reading by reading.
What the waves cost
Serialization moves wall clock and leaves billed minutes alone, which is why it survives budget reviews. Assume a suite of 12 jobs, each taking 5 minutes, each on warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) at $0.008 per minute from the pricing page, checked on 2026-08-13. Total compute is 60 job-minutes in every row below.
| Free slots | Waves | Wall clock per run | Billed job-minutes | Runner cost per run |
|---|---|---|---|---|
| 4 | 3 | 15 min | 60 | $0.48 |
| 6 | 2 | 10 min | 60 | $0.48 |
| 12 | 1 | 5 min | 60 | $0.48 |
At 300 runs a month the suite bills 18,000 job-minutes for $144 in all three rows. Moving from the 4-slot row to the single-wave row returns 10 minutes of wall clock per run, or 3,000 minutes a month of engineers waiting on a pull request check, and the invoice column does not move.
Removing the pool as a cause
Creates a fresh machine for each job when that job queues. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Per the cloud runners documentation, that statement covers generally available features on Linux and Windows runners; macOS concurrency runs on a per-organization quota, so teams sizing a large macOS matrix arrange the quota with support at [email protected].
That removes cause 3 and leaves the other two where they were. An account ceiling still counts jobs on GitHub-hosted runners, and a concurrency group you wrote still serializes runs on any runner, managed or not, so keep reading the signal.
Related Questions
How do I tell a concurrency group from a capacity shortage?
A concurrency group announces itself. The run header carries a Waiting banner naming the group, and queued runs flip to Canceled when a newer commit lands on the same ref under the default queue: single. A capacity shortage produces no banner: jobs sit at Waiting for a runner while queue wait on one runner label climbs in the Queue Timings report and other labels start on time. The concurrency group definition covers the group settings, and the guide to GitHub Actions concurrency limits maps each symptom to the mechanism behind it.
Do GitHub account job ceilings apply to jobs on WarpBuild runners?
The per-plan concurrent job ceilings of 20, 40, 60, and 500 count jobs on GitHub-hosted runners, per GitHub's usage limits reference, checked on 2026-08-13. Jobs that target warp- labels queue on pool capacity instead. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, covering generally available features on Linux and Windows runners per the cloud runners documentation; for high macOS concurrency, contact [email protected]. A concurrency group you wrote still serializes runs on any runner.
Where do I see how long jobs wait on each runner label?
The Queue Timings report has one row per runner label and stack with run count, queue time P75, and queue time P90, plus a daily average chart and CSV export, documented in the reports documentation. Sort by P90 and filter to one label at a time to separate a burst from a steady shortage. When a label shows no jobs starting at all, check bot repository access, runner group access, and workflow path restrictions in the common issues documentation before adding capacity. The guide to GitHub Actions queue times matches each reading to a fix, and per-minute rates for every runner size are on the pricing page.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.