GitHub Actions Concurrency Limits, Explained
GitHub Actions concurrency limits come from the workflow concurrency key, plan-level job ceilings, and runner pool capacity. Match the symptom to the limit.
The phrase "GitHub Actions concurrency limits" covers three separate mechanisms: the concurrency key in workflow YAML, the concurrent job ceiling attached to your GitHub plan, and the capacity of the runner pool behind your runs-on labels. Each one queues work in a different way, and each one has a different fix. This guide separates the three, maps each symptom to the mechanism behind it, and shows the configuration that clears each one, including how teams remove the pool as a constraint by moving jobs to WarpBuild runners.
Diagnosis
When an engineer says "we are hitting concurrency limits", they mean one of three things. Fixing the wrong one burns a sprint, so identify the mechanism before touching YAML.
1. The workflow-level concurrency key
The concurrency key is a throttle you write yourself. It can sit at the top of a workflow, where it governs whole runs, or inside a job, where it governs that job across runs. Every run or job carrying the same group name shares one lane.
A concurrency group holds one running item and one pending item. When a new run enters a group that already has a pending run, GitHub cancels the older pending run and parks the newer one. With cancel-in-progress: true, the new arrival also cancels the item that is currently running. GitHub documents this behavior in the workflow syntax reference.
The key never makes anything start sooner. It cancels work or parks it. So the symptoms it produces are pending runs and canceled runs rather than a long queue of jobs.
2. Plan-level concurrent job ceilings
Standard GitHub-hosted runners draw from shared pools with a fixed ceiling on concurrent jobs. The ceiling depends on your GitHub plan, and macOS has its own lower sub-ceiling. Figures below are from GitHub's usage limits reference, checked on 2026-08-13.
| Plan | Concurrent jobs | Concurrent macOS jobs |
|---|---|---|
| Free | 20 | 5 |
| Pro | 40 | 5 |
| Team | 60 | 5 |
| Enterprise | 500 | 50 |
Jobs above the ceiling queue until a slot frees. The ceiling is account wide, so a large matrix fan-out in one repository can starve every other repository's workflows. Larger GitHub-hosted runners carry separate limits that GitHub documents alongside these.
The macOS sub-ceiling explains a pattern many iOS teams recognize: Linux jobs start immediately while macOS jobs sit queued, because five macOS slots serve the whole account on Free, Pro, and Team plans.
3. Runner pool capacity
Jobs that target self-hosted or managed labels sit outside the plan ceilings. Their queue is governed by how many matching runners can come online. For a static self-hosted fleet, that is the number of registered machines. For an autoscaled fleet, it is the controller's maximum, such as the max runners setting on an ARC scale set. For a managed provider, it is whatever capacity the provider holds behind the label.
A job whose labels match zero online runners queues forever, which looks like a capacity problem and is actually misrouting. The common issues doc walks through the registration and permission checks that separate the two.
Which limit is yours
| Symptom | Mechanism |
|---|---|
| A run shows a "Waiting" banner naming a concurrency group | Workflow concurrency key |
| A queued run flips to "Canceled" when a newer commit lands | Workflow concurrency key |
| Running job count plateaus at 20, 40, 60, or 500 while the rest queue | Plan ceiling |
| macOS jobs queue while Linux jobs on the same plan start | Plan ceiling, macOS sub-limit |
| Jobs on one label queue while jobs on other labels start | Pool capacity for that label |
| Jobs on a label never start at all | Misrouting or permissions |
Measure before changing anything. For jobs on WarpBuild runners, the Queue Timings report in the dashboard breaks queue wait down per runner label and stack, with P75 and P90 percentiles, a daily chart, and CSV export. The Jobs report adds per-job queue time percentiles next to duration, CPU, and memory. Both are part of WarpBuild's CI observability surface, documented in the reports doc. A P90 that spikes during business hours points at ceiling contention. A P90 that stays high on one label around the clock points at pool capacity for that label.
For jobs on GitHub-hosted runners, subtract each job's queued timestamp from its started timestamp in the REST API output. If queue wait is high on every label at every hour, the problem is queue mechanics rather than a ceiling; the guide to GitHub Actions queue times covers that cluster.
Fix
Fix the mechanism you diagnosed, in this order of cheapness.
Fix the concurrency key
Most concurrency key problems are scoping accidents. The standard shape scopes the group to one workflow on one ref:
- Build the group name from
${{ github.workflow }}-${{ github.ref }}so two branches never share a lane. - Set
cancel-in-progresswith an expression such as${{ github.ref != 'refs/heads/main' }}so pull request pushes cancel stale runs while main always finishes. - Give deploy jobs their own job-level group per environment without cancellation, so deploys serialize instead of canceling each other mid-release.
- Audit for a static group name shared across workflows. A bare
concurrency: productionpasted into three workflows forces all three into one lane, which reads as a mysterious queue.
Fix the plan ceiling
The ceiling on standard GitHub-hosted runners has no configuration knob. You have three options.
First, reduce peak demand: path filters so documentation changes skip the test matrix, and a merge queue so batches share runs. Second, upgrade the plan, which moves the ceiling from 20 to 40, 60, or 500 concurrent jobs. Third, change runs-on. The ceiling follows the runner pool rather than the workflow, so relabeling a job removes it from the count entirely.
Fix pool capacity
For a self-hosted fleet, add machines or raise the autoscaler maximum, and budget for the idle cost that headroom implies. For a managed pool, pick one sized so your fan-out clears.
Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Capacity adjusts dynamically as workflows fan out. That statement carries the same scope as the runner docs: features that are Generally Available support unlimited concurrency on Linux and Windows runners, while features in beta may have limits. If your use case requires high concurrency on macOS runners, contact [email protected].
Teams that need pool capacity inside their own cloud account can bring their own cloud. BYOC runs on AWS, GCP, and Azure. Pool size then follows your cloud quotas instead of a vendor pool.
Moving a matrix-heavy suite off a capped pool is a one-line change per job: relabel runs-on and the ceiling no longer applies. For the broader speed work that usually follows, see the guide to speeding up GitHub Actions.
Configuration
The workflow below combines both halves of concurrency control: a concurrency block that throttles runs per branch, and a matrix that fans one test suite out across twelve parallel jobs on three runner labels.
name: test-suite
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
strategy:
fail-fast: false
matrix:
runner:
- warp-ubuntu-latest-x64-4x
- warp-ubuntu-latest-arm64-4x
- warp-windows-latest-x64-4x
shard: [1, 2, 3, 4]
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4What each piece does:
- The
groupexpression keeps every branch in its own lane, so a push to a feature branch never parks a run on main. - The
cancel-in-progressexpression cancels superseded runs on pull request branches and lets main runs finish, which keeps deploy gates trustworthy. fail-fast: falselets the other eleven shards finish when one fails, so a flaky shard reports alongside real results instead of masking them.- The matrix produces 3 runner labels x 4 shards = 12 jobs per run. GitHub caps a matrix at 256 jobs per workflow run, checked on 2026-08-13 against GitHub's usage limits reference.
- If a downstream dependency rate-limits you, add
max-parallelunderstrategyto cap the fan-out below the matrix size.
The three labels resolve to these machines, from the WarpBuild runner catalog:
| runs-on label | OS | vCPU | RAM | Rate per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | $0.008 |
| warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16GB | $0.006 |
| warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | $0.016 |
Sizes run from 2 to 32 vCPU per platform. The full Windows lineup, including Windows Server 2025 and the Visual Studio 2026 labels, is cataloged on the Windows runners page.
For deploys, move the group to the job level and drop cancellation, so releases queue behind each other instead of killing an in-flight rollout:
deploy:
needs: test
runs-on: warp-ubuntu-latest-x64-4x
concurrency:
group: deploy-production
steps:
- run: ./scripts/deploy.shA label typo queues a job forever with no logs. If a job never starts, work through the common issues checklist before assuming a capacity problem.
Cost or Time Model
Concurrency changes wall clock time and leaves billed minutes alone. Here is the arithmetic for a realistic suite.
Assumptions, stated up front: a test suite split into 24 shards; each shard takes exactly 8 minutes; shards are uniform, with no retries and no queue-to-start delay once a slot is free; billing is per minute. Total compute is 24 x 8 = 192 job-minutes per run in every scenario.
Serialized. The suite runs on standard GitHub-hosted runners in an account on the Free plan, where other workflows leave 6 of the 20 slots free. The 24 shards move through 6 slots in 4 waves of 8 minutes each.
Fanned out. The same suite targets a runner pool without a hard cap. All 24 shards start together.
| Scenario | Available slots | Waves | Wall clock | Billed minutes |
|---|---|---|---|---|
| Serialized behind a shared ceiling | 6 | 4 | 32 min | 192 |
| Fanned out on uncapped labels | 24 | 1 | 8 min | 192 |
The fan-out returns 24 minutes of wall clock per run. At 30 runs a day, that is 720 minutes of engineer waiting removed daily, from a change that costs zero extra billed minutes.
Now price those 192 minutes. The standard GitHub-hosted Linux runner for private repositories, the 2 vCPU, 8GB ubuntu-latest shape, bills at $0.006 per minute, from GitHub's pricing reference, checked on 2026-08-13. Note that GitHub rounds each job up to the next full minute, per the GitHub Actions billing docs; the model uses whole 8-minute shards, so rounding adds nothing here.
| Runner | Rate per minute | Billed minutes | Cost per run |
|---|---|---|---|
| GitHub-hosted Linux, standard, private repos (2 vCPU, 8GB) | $0.006 | 192 | $1.15 |
| warp-ubuntu-latest-x64-2x (2 vCPU, 8GB) | $0.004 | 192 | $0.77 |
| warp-ubuntu-latest-x64-4x (4 vCPU, 16GB) | $0.008 | 192 | $1.54 |
| warp-ubuntu-latest-arm64-4x (4 vCPU, 16GB) | $0.006 | 192 | $1.15 |
Two readings of that table. Fan-out is free in dollars: the serialized and fanned-out scenarios cost the same on any given runner, so there is no budget reason to accept a 32-minute wall clock. And matched shape for shape, the runner rate sets the whole bill: warp-ubuntu-latest-x64-2x (2 vCPU, 8GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest, the same 2 vCPU, 8GB shape, a 33 percent lower list price, GitHub pricing checked on 2026-08-13.
Full rates by runner type are on the WarpBuild pricing page. If individual shards are also slower than they should be, the answer page on why GitHub Actions are slow covers the runner-side causes.
FAQ
Does the concurrency key limit how many jobs run in parallel?
No. The concurrency key throttles workflow runs, or single jobs when set at the job level, within a named group to one running plus one pending. Parallel jobs inside a single run are governed by your plan's job ceiling and by runner pool capacity.
Do GitHub's concurrency limits apply to self-hosted runners?
The per-plan concurrent job ceilings apply to GitHub-hosted runners only. Jobs that target self-hosted or managed labels queue on pool capacity instead. Repository-level limits, such as the 256-job matrix cap, still apply.
How many jobs can a single matrix create?
A matrix generates at most 256 jobs per workflow run. Beyond that, split the suite into multiple workflows or collapse a matrix dimension.
Does WarpBuild limit concurrency?
Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. The docs scope unlimited concurrency to GA features on Linux and Windows runners. For high concurrency on macOS, contact [email protected].
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.