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.
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
List recent runs with
GET /repos/{owner}/{repo}/actions/runs(orgh api repos/{owner}/{repo}/actions/runs) and note each run'screated_at.For each run, pull its jobs with
GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobsand compute queue time per job asjob.started_atminus the run'screated_at, not the job's owncreated_at, which is stamped late for a gated job once itsneeds:dependency resolves.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.
For the worst offenders, group by
head_branchand theconcurrency.groupkey 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).Read the queued jobs'
labelsfield and compare against available runner capacity, usingGET /repos/{owner}/{repo}/actions/runnersfor self-hosted pools, to rule in or out a scarce runner label (cause: right-sizing).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.
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.
Fix: Concurrency groupsConfirm it
Pull the run's jobs with
GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobsand comparestarted_atagainst the run'screated_atfor runs sharing the sameconcurrency.groupkey defined in the workflow file; a wait that tracks the prior run's duration for that key points here.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.
Fix: Superseded runs cancelledConfirm it
List recent runs for the branch with
GET /repos/{owner}/{repo}/actions/runs?branch=<branch>and check whether a later run'screated_atfalls before an earlier run'supdated_at, meaning they overlapped; then check the workflow'sconcurrency:block for a missing group orcancel-in-progress: false.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.
Fix: Right-sized runnersConfirm it
Read each queued job's
labelsfield fromGET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs, and for self-hosted labels checkGET /repos/{owner}/{repo}/actions/runnersto see how many runners carry that label and whether they are alreadybusy: true.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.
Fix: Path filtersConfirm it
Inspect the heaviest workflow's trigger block with
yq '.on.push, .on.pull_request' .github/workflows/<file>.ymlfor a missingpaths/paths-ignore, then confirm with the run history whether a docs-only PR's commit produced a full run viaGET /repos/{owner}/{repo}/actions/runs?event=pull_request.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.
Fix: Long-running jobs splitConfirm it
Pull job timing with
GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobsfor the runs sitting behind it, compute each step's duration fromsteps[].started_atandcompleted_at, and look for a setup step over 60 seconds or a Post step over 30 seconds inflating the job's occupied time.
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.
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.
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.
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.
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