GitHub Actions Queue Times and How to Fix Them

GitHub Actions queue time is the wait between a job being queued and a runner claiming it. Trace it to concurrency caps, labels, or pool capacity.

Last verified:

GitHub Actions queue time is the gap between the moment a job enters the queue and the moment a runner picks it up, and long waits trace to five causes: account concurrency ceilings, runner group misconfiguration, label mismatches, saturated self-hosted pools, and matrix fan-out beyond available capacity. WarpBuild removes the capacity causes by provisioning a fresh runner for every job, and its Queue Timings report measures the wait per runner label so you can confirm the fix.

This guide walks through each cause, the fix that matches it, the configuration to measure queue wait per runner label and stack, and a model that converts queue minutes per week into engineer hours.

Diagnosis

Queue time is easy to miss because GitHub records it and then bills you nothing for it. The workflow run page shows when a job was queued and when it started, and the difference is the wait. In the API, each job object carries created_at and started_at timestamps, so started_at minus created_at is the queue wait for that job. Most teams only notice the number when someone watches a pull request check sit at "Waiting for a runner". The five causes below cover nearly every long queue.

Account-level concurrency ceilings

Every GitHub plan caps how many jobs can run at the same time on GitHub-hosted runners, and the cap applies across the whole account. When the account hits its ceiling, each additional job queues, whatever repository it belongs to and however much capacity GitHub has free. macOS jobs have a separate, much lower ceiling on every plan, which is why iOS pipelines are usually the first to stack up. The per-plan numbers are listed in GitHub's usage limits documentation (checked on 2026-08-13). The practical symptom: a busy release day in one repository pushes every other team's checks into the queue.

Runner group misconfiguration

Runner groups control which repositories and workflows may use a set of runners. If a repository is missing from the group's allowed list, its jobs wait for a runner that will never claim them. Workflow restrictions add a second filter: a group can be pinned to specific workflow paths, branches, or tags, for example cd.yaml on main only, and a job from any other workflow queues silently. WarpBuild documents the checks for both cases in the common issues guide, including where to verify group access in the GitHub organization settings.

Label mismatch leaving jobs unclaimed

A job runs only on a runner that carries every label in its runs-on value. A typo in one label, or a label that no registered runner advertises, leaves the job unclaimed. The symptom is distinctive: the job stays queued indefinitely and the log view stays empty, because no runner ever accepted it. Capacity problems look different, since those jobs do start once a runner frees up. A job that sits for hours rather than minutes points to labels, so check the spelling against the runner catalog before anything else.

Self-hosted pool saturation

A self-hosted pool has a fixed number of registered runners. Jobs beyond that number wait until a runner frees up, so queue time grows with load: quiet mornings look fine and the 5 pm merge rush does not. Autoscaled pools built on Actions Runner Controller shrink the problem but keep a floor of scale-up latency, because a new runner needs a pod scheduled, an image pulled, and a registration completed before it can claim its first job. Every one of those steps lands in the queue-time column.

Matrix fan-out exceeding available capacity

A matrix multiplies one job definition into many jobs, and all of them want a runner at the same moment. A 16-way test matrix on a pool of 4 self-hosted runners executes in 4 waves: the first 4 shards start immediately, the next 4 queue for one full shard duration, and the last 4 wait through 3. Average queue time across the matrix climbs even though the pool is running flat out. The wall clock for the suite becomes the shard duration multiplied by the number of waves, which defeats the point of sharding in the first place.

Fix

Start by separating never-starts from starts-late. A job that stays queued with an empty log view points to labels or runner groups. A job that starts after minutes of waiting points to capacity.

For label mismatches, copy the label string from the runner catalog rather than typing it. WarpBuild labels encode operating system, architecture, and size in one string, for example warp-ubuntu-latest-x64-8x for 8 vCPU on Ubuntu 24.04, so a single wrong character produces a label that nothing advertises.

For runner group problems, work through the two checks in the common issues guide: confirm the group allows the repository, then confirm workflow restrictions match the workflows you actually run.

The three capacity causes share one fix: stop making jobs share a fixed pool. Provisions an ephemeral virtual machine for each job at the moment the job queues. There is no pool to size and no autoscaler to tune. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That applies to generally available features on Linux and Windows runners; if your use case needs high concurrency on macOS runners, contact [email protected].

Because each runner is created for one job and destroyed afterward, a 16-way matrix gets 16 machines and a 100-job release day gets 100. Queue waits caused by account ceilings, pool saturation, or matrix bursts drop to the provisioning time of a fresh machine. The Queue Timings report gives you the per-label numbers to verify it on your own workflows.

Two boundary notes. If "concurrency" means three different things in your incident channel, the guide to GitHub Actions concurrency limits separates the workflow-level concurrency key, account ceilings, and pool capacity. And once jobs start promptly and the build itself becomes the bottleneck, continue with the guide to speeding up GitHub Actions builds, or share the short version from why are GitHub Actions slow.

Configuration

Measure queue wait per runner label

The Queue Timings report in the WarpBuild dashboard breaks queue wait down per runner label and stack. The daily chart shows average queue time and total job count per day over a selected date range. The table shows one row per runner label and stack combination, with run count, queue time P75, and queue time P90, and it filters by runner label and stack. CSV export includes every row matching the current filters, so the data drops straight into a spreadsheet.

The Jobs report adds per-job context. For each repository, workflow, and job name combination it tracks duration and queue time at P75 and P90 over time, which makes a queue regression visible as a line bending upward on the time-series chart. CPU and memory percentiles appear in the same table when CI observability is enabled on the runners. The full reference is in the reports documentation, and the guide to runner metrics covers which of these numbers deserve a weekly look.

Pull the numbers from the API

The Queue Timings report is the GetQueueTimings operation, served at GET /reports/queue-timings:

curl -G "https://api.warpbuild.com/api/v1/reports/queue-timings" \
  -H "Authorization: Bearer $WARPBUILD_API_KEY" \
  --data-urlencode "start_date=2026-08-01T00:00:00Z" \
  --data-urlencode "end_date=2026-08-13T00:00:00Z" \
  --data-urlencode "runner_labels=warp-ubuntu-latest-x64-8x" \
  --data-urlencode "sort_by=queue_time_p90"

The response aggregates queue time per runner label and stack at P75 and P90 and includes a daily series that splits total queue time into GitHub time and WarpBuild time, so you can see which side of the handoff the wait comes from. Add format=csv for the spreadsheet version, or narrow the result with the runner_labels, stack_ids, and categories filters.

Fan a matrix out on warp- labels

On a fixed pool, a matrix serializes. With four registered self-hosted runners, the eight shards below run in two waves, and shards five through eight log a queue time equal to one full shard duration:

jobs:
  tests:
    runs-on: [self-hosted, linux, x64]
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/8

The same matrix on a WarpBuild label fans out fully. Each shard gets its own machine with 8 vCPU, 32GB of memory, and a 150GB SSD, provisioned when the job queues:

jobs:
  tests:
    runs-on: warp-ubuntu-latest-x64-8x
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/8

The change that matters is the runs-on line. All eight shards start together, the wall clock for the suite falls to one shard duration plus provisioning, and the queue-time column in the report flattens. Run it for a week, then compare P90 queue time per label before and after in the Queue Timings report.

Cost or Time Model

Queue minutes into engineer hours

Queue minutes are unbilled, which makes them easy to ignore and expensive to keep. The model below converts them into engineer wait. Substitute your own numbers from the Queue Timings report.

Assumptions:

  • A team of 25 engineers.
  • 6 workflow runs per engineer per working day, 5 working days per week: 750 runs per week.
  • Average queue wait of 4 minutes per run, taken as the P75 from the report.
  • 1 run in 3 gates a person who is actively waiting, such as a pull request check before review or merge.

The arithmetic: 750 runs multiplied by 4 minutes is 3,000 queue minutes per week. One third of that is actively watched: 1,000 minutes, or roughly 17 engineer hours per week. At a loaded cost of $100 per engineer hour, queue wait costs this team about $1,700 per week, or about $88,000 per year, before counting the context switches that follow every stalled check.

Runner minutes at list price

Moving jobs onto per-job runners changes the billed side too. GitHub-hosted runners bill per minute at public list prices, published in the GitHub Actions billing reference (checked on 2026-08-13). WarpBuild per-minute rates for the matching vCPU counts are on the pricing page.

vCPUGitHub-hosted Linux rate per minuteWarpBuild Linux x64 labelWarpBuild rate per minute
2$0.006warp-ubuntu-latest-x64-2x$0.004
4$0.012warp-ubuntu-latest-x64-4x$0.008
8$0.022warp-ubuntu-latest-x64-8x$0.016
16$0.042warp-ubuntu-latest-x64-16x$0.032
32$0.082warp-ubuntu-latest-x64-32x$0.064

A worked example using the eight-shard matrix above. Assume each shard runs 6 minutes and the workflow runs 1,000 times per month:

  • Runner minutes: 8 shards x 6 minutes x 1,000 runs = 48,000 minutes per month.
  • GitHub-hosted 8 vCPU Linux: 48,000 x $0.022 = $1,056 per month.
  • warp-ubuntu-latest-x64-8x: 48,000 x $0.016 = $768 per month.
  • Computed difference: $288 per month at this volume.

That arithmetic holds the shard duration constant on both sides.

FAQ

Does GitHub Actions queue time count as billable minutes?

No. Billing starts when the job begins running, so queued minutes cost nothing on the invoice and surface as engineer wait instead. The time model on this page converts that wait into hours and dollars.

Why does my job sit at Waiting for a runner with an empty log?

Either the runs-on labels match no online runner or a runner group excludes the repository or workflow. Check the label spelling against the runner catalog first, then verify group access and workflow restrictions.

Do WarpBuild runners have concurrency limits?

Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That covers generally available features on Linux and Windows runners.

How do I measure queue time per runner label?

The Queue Timings report shows P75 and P90 queue wait per runner label and stack, with CSV export. The same data comes from the GetQueueTimings API operation at GET /reports/queue-timings.

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.