Why CI runs sit queued before any job starts

Queued runs with no runner assigned almost always trace back to concurrency and cancellation settings that serialize or pile up work, not to a shortage of GitHub Actions capacity. Measure queue time from run history first, then confirm the cause before touching any workflow file.

runtime · needs run history
How StarSling works

AI agents find the cause

StarSling agents run this diagnosis against your workflows and run history, identify which cause is yours, and open the fix as a reviewable PR.

Symptoms

When a job in a GitHub Actions run sits queued with no runner assigned, GitHub has accepted the run but has not yet started the job, and that delay is visible only in run history, not in the workflow YAML. The number to trust is a job's started_at minus its run's own created_at, pulled from the Actions REST API, because a gated job's own created_at is stamped only once its needs dependency resolves, which understates how long the run actually waited from trigger to first job start. Most repos hit this ceiling for one of a handful of reasons, and they compound: a concurrency group scoped too broadly serializes runs that could otherwise execute side by side, superseded pushes that were never cancelled keep occupying runner slots long after their result stopped mattering, a scarce runner label such as self-hosted, larger, or macOS creates its own queue independent of any concurrency setting, workflows without path filters multiply the number of runs competing for the same pool, and a handful of long-running jobs each hold a runner or a concurrency slot well past what their actual work requires. Checking them in this order moves from the most common, highest-leverage fix toward the narrower ones, because fixing concurrency and cancellation policy often clears most of the backlog before runner supply or trigger scope need any attention at all.

  • A run sits in the Actions UI with a job marked Queued for well over a minute before any log output appears.

  • Several runs for the same pull request or branch are all visible in the run list at once, and only the newest one is doing useful work.

  • Queue time gets noticeably worse right after several commits land close together, even though nobody changed the workflow.

  • One job in a run, often the one requesting a self-hosted or larger runner, queues far longer than the other jobs in the same run.

  • A small change, like a README edit, produces the same long wait before its job starts as a full code change does.

How to diagnose it

  1. List recent runs with GET /repos/{owner}/{repo}/actions/runs (or gh api repos/{owner}/{repo}/actions/runs) and note each run's created_at.

  2. For each run, pull its jobs with GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs and compute queue time per job as job.started_at minus the run's created_at, not the job's own created_at, which is stamped late for a gated job once its needs: dependency resolves.

  3. Sort jobs by queue time and check whether the P90 exceeds roughly 60 seconds for pull request runs or 120 seconds for release or schedule runs.

  4. For the worst offenders, group by head_branch and the concurrency.group key from the workflow file; check whether runs on the same branch overlap in time (cause: cancel-superseded), or whether unrelated jobs are queued behind one broad group (cause: concurrency-groups).

  5. Read the queued jobs' labels field and compare against available runner capacity, using GET /repos/{owner}/{repo}/actions/runners for self-hosted pools, to rule in or out a scarce runner label (cause: right-sizing).

  6. Check whether the queued runs were triggered by changes unrelated to the workflow's purpose, meaning it lacks paths/paths-ignore (cause: path-filter), and whether a job sharing that pool routinely runs long due to a slow setup or bloated post step (cause: long-running-jobs).

Likely causes

Ordered by how often each one turns out to be the answer. Confirm a cause with its check before you change anything.

  1. A concurrency group serializes runs that could execute side by side

    When a concurrency group key is scoped too broadly, for example per-workflow instead of per-workflow-per-branch, every run sharing that key waits for the prior run in the group to finish before GitHub even assigns it a runner. The wait shows up as queued time in job history, but it is really the previous run's remaining duration, not a shortage of runners.

    Confirm it

    Pull the run's jobs with GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs and compare started_at against the run's created_at for runs sharing the same concurrency.group key defined in the workflow file; a wait that tracks the prior run's duration for that key points here.

    Fix: Concurrency groups
  2. Superseded runs from the same branch pile up waiting for a runner

    Without cancel-in-progress, every push to a branch keeps its prior run alive and running or queued, competing for the same limited runner pool even though nobody will use its result once a newer commit lands. A concurrency group with cancel-in-progress set to false, or no concurrency block at all on a push-triggered workflow, produces the same pileup.

    Confirm it

    List recent runs for the branch with GET /repos/{owner}/{repo}/actions/runs?branch=<branch> and check whether a later run's created_at falls before an earlier run's updated_at, meaning they overlapped; then check the workflow's concurrency: block for a missing group or cancel-in-progress: false.

    Fix: Superseded runs cancelled
  3. Jobs are waiting on a scarce runner label, not a concurrency group

    A job pinned to a runner label in limited supply, such as a larger hosted runner, macOS, or a self-hosted pool with only a few machines, queues behind every other job requesting that same label, even while standard Linux runners sit idle. This is a distinct mechanism from concurrency serialization: it is org-level or repo-level capacity for one label, not run-to-run ordering.

    Confirm it

    Read each queued job's labels field from GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs, and for self-hosted labels check GET /repos/{owner}/{repo}/actions/runners to see how many runners carry that label and whether they are already busy: true.

    Fix: Right-sized runners
  4. A workflow without path filters runs, and queues, on every push

    If a workflow's on block has no paths or paths-ignore, every commit, including a docs-only or unrelated-package change, triggers the same full workflow, multiplying the number of runs competing for the same runner pool and concurrency group. More runs in flight means a deeper queue for everyone sharing that pool, independent of any single run's own cost.

    Confirm it

    Inspect the heaviest workflow's trigger block with yq '.on.push, .on.pull_request' .github/workflows/<file>.yml for a missing paths/paths-ignore, then confirm with the run history whether a docs-only PR's commit produced a full run via GET /repos/{owner}/{repo}/actions/runs?event=pull_request.

    Fix: Path filters
  5. A few long jobs occupy runners and concurrency slots longer than needed

    A job with a slow, uncached setup step or a bloated post step, such as a large cache upload, holds its runner and its concurrency-group slot for longer than the actual build or test work requires. Every other run sharing that pool or group queues behind it for the full inflated duration, not just the useful portion of it.

    Confirm it

    Pull job timing with GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs for the runs sitting behind it, compute each step's duration from steps[].started_at and completed_at, and look for a setup step over 60 seconds or a Post step over 30 seconds inflating the job's occupied time.

    Fix: Long-running jobs split

Hand it to an agent

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this diagnosis against your repository and report which cause it found.

Prompt for your coding agent
Diagnose high CI queue time in this repository using GitHub's run history, not the workflow file alone. Pull recent runs with `GET /repos/{owner}/{repo}/actions/runs` and their jobs with `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs`. Compute each job's queue time as `job.started_at` minus the run's own `created_at`, not the job's own `created_at`, which is stamped late for a gated job once its `needs:` dependency resolves. Flag jobs whose queue time exceeds roughly 60 seconds for pull request runs or 120 seconds for release runs. Then test causes in this order: first, whether queued runs share an overly broad `concurrency:` group; second, whether runs on the same branch overlap in time, meaning superseded runs were never cancelled; third, whether queued jobs request a scarce runner label, checked against `GET /repos/{owner}/{repo}/actions/runners` for self-hosted pools; fourth, whether the workflow lacks `paths`/`paths-ignore` and runs on unrelated changes; fifth, whether a long-running job is occupying the shared pool or concurrency slot longer than its work requires. Report which cause the evidence supports before changing any workflow file.

Go further

Find the cause, fix them all, or keep them fixed.

You have the prompt that works out which cause applies. Here is how much further you can take it, each step doing more for you than the last.

  1. Find the cause

    Copy the diagnosis prompt above

    Hand it to your coding agent to confirm which cause applies in your repo. It reports back what it found and changes nothing.

  2. Fix them all, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (these 8 plus 65 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

Sources

1REST API: List workflow runs for a repository (opens in new tab)

2REST API: List jobs for a workflow run (opens in new tab)

3Using concurrency to control workflow and job runs (opens in new tab)

4Triggering a workflow: filtering by paths changed (opens in new tab)

Last updated 2026-08-31