Concurrency Limit

A concurrency limit is a ceiling on how many GitHub Actions jobs run at the same time, set by a workflow concurrency key, the account plan, or runner capacity.

Definition

A concurrency limit is a ceiling on how many GitHub Actions jobs are allowed to run at the same time. The ceiling can come from three separate places: the concurrency key written into a workflow, the concurrent job allowance attached to the account plan, or the number of runners available to accept the queued jobs.

The phrase is used loosely, and that is where most of the confusion around it starts. Two engineers can both say "we are hitting a concurrency limit" and mean different mechanisms, with different symptoms and different fixes.

The three ceilings, separated

CeilingWhere it is setWhat it countsTypical symptom
Workflow concurrency groupThe concurrency key in workflow YAML, at the workflow level or the job levelRuns, or jobs, sharing one group nameA run waits with a banner naming the group, or flips to cancelled when a newer commit lands
Account plan allowanceThe plan attached to the account, with no configuration knobConcurrent jobs on GitHub-hosted runners, counted across the whole accountThe running job count plateaus at a fixed number while everything else queues
Runner pool capacityThe size or the autoscaler maximum of the fleet behind a runs-on labelMachines able to accept a job carrying that labelJobs on one label queue while jobs on other labels start immediately

The three ceilings count different units. The first counts runs inside a named group. The second counts jobs across an account. The third counts machines behind a label. Mixing those units is the reason a fix applied to the wrong ceiling changes nothing about the wait.

The workflow concurrency group

The concurrency key is a throttle an engineer writes on purpose. It takes a group name, usually built from an expression, and an optional cancel-in-progress flag. Every run that resolves to the same group name shares one lane.

A group holds at most two items: one running and one pending. When a third item arrives, GitHub cancels the item that was pending and parks the new arrival in its place. With cancel-in-progress: true, the arrival also cancels whatever was running.

The key never makes anything start sooner. It cancels work or parks it, so the symptoms it produces are pending runs and cancelled runs rather than a long queue of jobs waiting on machines. Setting the key at the job level narrows the lane to that one job across runs, which is the usual shape for deployment jobs that have to serialize.

The account plan allowance

Standard GitHub-hosted runners draw from shared pools with a documented ceiling on concurrent jobs. The ceiling follows the plan attached to the account, and macOS carries a lower sub-ceiling of its own. The figures below are from GitHub's usage limits reference, checked on 2026-08-13.

PlanConcurrent jobsConcurrent macOS jobs
Free205
Pro405
Team605
Enterprise50050

Jobs above the ceiling queue until a slot frees. The count is account wide, so a large fan-out in one repository delays workflows in every other repository under the same account. The macOS sub-ceiling explains a pattern iOS teams see often: Linux jobs start right away while macOS jobs sit queued, because five macOS slots serve the entire account on the Free, Pro, and Team plans.

Two more documented ceilings sit near this one and are worth keeping separate in your head. A single workflow run generates at most 256 jobs from a matrix. A job that has been queued for 24 hours waiting on a self-hosted runner is cancelled. Both figures are from the same usage limits reference, checked on 2026-08-13.

Runner pool capacity

Jobs that target self-hosted or managed labels sit outside the plan allowance. Their ceiling is the number of matching runners that can come online at once.

For a static self-hosted fleet, that number is the count of registered machines. For an autoscaled fleet, it is the controller maximum, such as the maximum runner count configured on an Actions Runner Controller scale set. For a managed fleet, it is whatever capacity the provider keeps behind the label.

One failure mode hides inside this category. A job whose labels match zero online runners waits forever, which reads as exhausted capacity and is really misrouting: a typo in a label, a runner registered to one repository while the workflow lives in another, or a runner group that excludes the repository.

Example

A concurrency group cancelling an in-progress run

The workflow below puts every run for a pull request into one lane keyed by the ref, and cancels the in-progress run when a newer commit arrives.

name: pr-checks
on:
  pull_request:

concurrency:
  group: pr-checks-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make test

Here is what happens on a pull request that gets two pushes close together.

  1. At 09:00:00 a commit lands on the branch. Run 1 enters the group pr-checks-refs/pull/482/merge and starts, because the group is empty.
  2. At 09:02:10 a second commit lands on the same branch. Run 2 resolves to the same group name.
  3. Because cancel-in-progress is true, GitHub cancels run 1 where it stands. Run 1 ends with the conclusion Cancelled, part way through make test, and run 2 starts.

Change one line and the behavior changes with it. With cancel-in-progress: false, which is the default, run 1 finishes and run 2 waits as the pending item in the group. A third push would then cancel the pending run 2 and park itself in that slot, because the group holds one running item and one pending item and no more.

The group expression matters as much as the flag. pr-checks-${{ github.ref }} gives every pull request its own lane. A hard-coded name such as group: ci puts every branch and every workflow that copies the line into a single lane, which produces a queue that looks like a capacity problem and is a naming accident.

A ceiling nobody wrote down

The second workflow writes no concurrency key at all. Its ceiling comes from the fan-out itself and from the machines behind the labels.

name: test-matrix
on:
  push:
    branches: [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, 5, 6]
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/test.sh --shard=${{ matrix.shard }}/6

Three runner labels multiplied by six shards produce 18 jobs per run. The labels in the matrix are managed runner labels taken from the WarpBuild runner catalog, and the mechanism is the same for GitHub-hosted labels or for self-hosted labels: the fan-out asks for 18 slots, and whichever ceiling is lowest decides how many of the 18 start together.

Hold the work constant and vary only the slots. Assume each shard takes 5 minutes, shards are uniform, there are no retries, and a job starts the moment a slot frees. Total job time is 18 x 5 = 90 job-minutes in every case. Wave count is 18 divided by the available slots, rounded up.

Slots availableWavesWall clock per runJob-minutes consumed
6315 min90
9210 min90
1815 min90

The arithmetic shows what a concurrency limit does and what it leaves alone. It sets wall clock time, so the engineer waiting on the pull request feels it directly. Total job time stays at 90 minutes across all three rows, so the metered work is identical whether the suite clears in one wave or three.

Two knobs sit next to this. max-parallel under strategy caps a matrix below its natural size on purpose, which is how teams protect a rate-limited dependency such as a shared staging database. And the 256-job matrix cap is a hard stop: a matrix that expands past it fails to expand rather than queueing, so very wide matrices have to be split across workflows.

Reading the symptom back to the ceiling

  • A waiting banner that names a group, or runs that flip to Cancelled when a newer commit lands, points at a workflow concurrency group.
  • A running job count that plateaus at 20, 40, 60, or 500 while the rest queue points at the plan allowance.
  • macOS jobs queueing while Linux jobs on the same account start points at the macOS sub-ceiling.
  • Jobs on one label queueing while other labels start points at pool capacity for that label.
  • Jobs on a label that never start at all point at misrouting or permissions rather than any ceiling.

Measure before changing anything. Subtract each job's queued timestamp from its started timestamp in the workflow run API output to separate waiting from running, then group that wait by label and by hour. A wait that spikes during working hours and clears overnight behaves like contention for a shared ceiling. A wait that stays flat around the clock on one label behaves like a pool sized too small for that label.

FAQ

What is a concurrency limit in GitHub Actions?

A concurrency limit is a ceiling on how many jobs are allowed to run at the same time. Three separate ceilings carry that name: the concurrency key written into a workflow, the concurrent job allowance attached to the account plan, and the number of runners available to accept jobs for a given runs-on label.

Does the concurrency key limit how many jobs run in parallel?

No. The concurrency key throttles workflow runs, or single jobs when it is set at the job level, within a named group to one running item plus one pending item. Parallel jobs inside a single run are governed by the plan allowance and by runner pool capacity instead.

How many GitHub Actions jobs can run at the same time?

On GitHub-hosted runners the ceiling follows the plan: 20 concurrent jobs on Free, 40 on Pro, 60 on Team, and 500 on Enterprise, with a lower macOS sub-ceiling of 5 jobs on Free, Pro, and Team and 50 on Enterprise. Figures from GitHub's usage limits reference, checked on 2026-08-13.

Is a concurrency limit the same thing as queue time?

Queue time is the symptom and a concurrency limit is one of its causes. Queue time is the gap between the moment a job is queued and the moment a runner starts it, and that gap grows whenever a ceiling holds the job back, whenever no runner matches the labels, or whenever the fleet is busy.

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.