Queue Time
Queue time is the wait between a GitHub Actions job entering the queue and a runner starting it, measured apart from how long the job then takes to run.
Queue time is the interval between the moment a GitHub Actions job enters the queue and the moment a runner claims it and starts the first step. It measures the wait for a machine, and it is a separate interval from run time, which covers how long the steps take once a machine is working on them.
Both intervals land on the same pull request check as wall clock, which is why they get blamed for each other. A team that shortens its test suite and sees no change in the time to a green check was usually waiting for a machine rather than running slow steps.
Definition
A GitHub Actions job carries three timestamps, and the two intervals between them are the whole subject. Each job object returned by the REST API has created_at, started_at and completed_at (REST reference for workflow jobs, checked on 2026-08-13).
| Interval | Arithmetic | What it measures |
|---|---|---|
| Queue time | started_at minus created_at | the wait for an eligible runner to claim the job |
| Run time | completed_at minus started_at | the steps, from checkout to conclusion |
| Wall clock | completed_at minus created_at | what a developer watching the check experiences |
Queue time is a property of one job rather than one run. A run made of twelve matrix legs has twelve queue times, and the run finishes when the slowest chain of queue plus run finishes. Reporting a single number per run therefore averages away the leg that waited.
Four things live inside a queue time, and they have different fixes:
- Every eligible machine is busy. The job waits for one to free up. This is a depth problem in the set of runners that carry the label.
- No machine exists yet. An autoscaled fleet creates one on demand, so the wait includes boot, agent registration, and image pull before the first step runs.
- A ceiling is holding the job back. Account concurrency limits, a
concurrencygroup in the workflow, or an unfinishedneedsdependency all keep a job out of a runner while nothing is wrong with capacity. - No machine will ever match. A misspelled label, a repository outside a runner group, or a workflow restriction leaves the job queued with an empty log. GitHub cancels a job that has waited 24 hours for a self-hosted runner (GitHub Actions limits, checked on 2026-08-13), so this failure mode surfaces a day after the push.
Cause three is worth separating in any measurement. A job blocked by needs or by a concurrency group is reported as waiting, so subtracting timestamps across every job in a run mixes dependency waiting into machine waiting. The workflow_job webhook avoids the ambiguity: it delivers queued when the job enters the queue, in_progress when a runner starts it, and completed when it ends, and each payload carries the job's labels array and runner_name (workflow_job webhook payload, checked on 2026-08-13). Recording those three deliveries gives both intervals with the routing key attached.
Read the result as percentiles. Queue time has a long tail by nature, since most jobs on a quiet afternoon start immediately and the jobs that queue arrive together during a busy hour. A mean across a week reports a healthy number while the P90 sits in minutes.
Example
This scheduled workflow reads finished runs from the REST API and prints wait time and run time per runner label, so a growing wait can be told apart from a slow build.
name: queue-time-report
on:
schedule:
- cron: "0 7 * * 1"
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
report:
runs-on: ubuntu-latest
steps:
- name: Split wait time from run time per runner label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api "repos/${{ github.repository }}/actions/runs" \
-f status=completed -F per_page=100 --jq '.workflow_runs[].id' \
| while read -r run_id; do
gh api "repos/${{ github.repository }}/actions/runs/$run_id/jobs" \
--jq '.jobs[]
| select(.started_at != null and .completed_at != null)
| {label: (.labels | join(",")),
wait: ((.started_at | fromdateiso8601) - (.created_at | fromdateiso8601)),
run: ((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601))}'
done \
| jq -s 'group_by(.label)[]
| {label: .[0].label,
jobs: length,
wait_p90: (map(.wait) | sort | .[(length * 0.9 | floor)]),
run_p90: (map(.run) | sort | .[(length * 0.9 | floor)])}'The output has one row per label. The numbers below illustrate the shape of a week from one repository rather than a measurement:
| Runner label | Jobs | Wait P90 | Run P90 |
|---|---|---|---|
ubuntu-latest | 355 | 8s | 5m 02s |
self-hosted,linux,x64 | 412 | 6m 40s | 4m 10s |
self-hosted,linux,arm64 | 96 | 1m 12s | 9m 30s |
Three labels, three different problems. The hosted label starts almost immediately and spends its time in the steps. The x64 self-hosted label waits longer than it runs, which points at how many machines carry that label and how quickly new ones appear. The ARM64 label starts quickly and runs for nine minutes, so adding machines there changes nothing and the steps are the place to look.
A single average across all three labels would report roughly four minutes of wait and hide the row that matters. Grouping by label keeps the routing key attached to the number, which is also the key a fix is applied to, since capacity, scope, and machine size are all configured per label.
Related Terms
- GitHub Actions queue times and how to fix them: the five causes behind a long wait and the fix that matches each one.
- Runner pool, the set of machines behind a label: how depth, warm floor, and idle timeout decide whether a job waits.
- Cold start, the machine creation time inside a wait: what a job pays for when no machine exists yet.
- Queue timings and job reports: P75 and P90 wait per runner label and stack, with CSV export.
- Runners not picking up jobs: the bot access, runner group, and workflow restriction checks for a job that never starts.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What is queue time in GitHub Actions?
Queue time is the interval between a job entering the queue and a runner claiming it and starting the first step. It measures the wait for a machine. Run time, the interval from the first step to the job conclusion, measures the work itself. A job that shows Waiting for a runner to pick up this job is accumulating queue time.
How do I measure queue time for a job?
Each job object in the GitHub REST API carries created_at, started_at and completed_at. Queue time is started_at minus created_at and run time is completed_at minus started_at. The workflow_job webhook gives the same two intervals from its queued, in_progress and completed deliveries without polling.
Why report queue time per runner label instead of one average?
The two intervals have different fixes and one average hides which one is growing. A label whose wait exceeds its run time points at capacity or routing. A label whose run time dominates points at the steps. Reporting P75 and P90 per label keeps the two apart, because a long tail on a small share of jobs disappears in a mean.
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.