Waiting for a runner to pick up this job in GitHub Actions
"Waiting for a runner to pick up this job" in GitHub Actions is not an error and never becomes one on its own: the job is queued and no runner has claimed it. Something about the labels, the runner group, your concurrency ceiling or your billing is preventing a match, and the log will not tell you which, so you check them in that order.

What this error means
The job sits in the run with a spinner and one line. Above it, GitHub prints the label set the job asked for, which is the only diagnostic information on the page: "Requested labels: self-hosted, linux, gpu". Nothing fails, nothing is annotated, and the run can stay that way indefinitely. On a self-hosted label the wait has a documented end, because "a job can be in the queue for 24 hours before it is automatically cancelled". What this shares with nothing else is that the workflow is correct, the runner may be healthy, and the job still never starts.
Requested labels: self-hosted, linux, gpu
Job defined at: acme/api/.github/workflows/ci.yml@refs/heads/main
Waiting for a runner to pick up this job...runs-on is an AND, and the requested labels line proves it
A job asks for every label in runs-on, and a runner is eligible only if it carries all of them. One extra label in the workflow, or one the machine never registered, and the set is unmatchable. GitHub prints the requested labels above the waiting line so you can compare them against the runner list in settings, character for character.
Case and spelling are part of the match, and a typo does not fail fast. ubuntu-24.04 and ubuntu-2404 are two different requests, and the second queues indefinitely rather than erroring: a misspelled GitHub-hosted label behaves exactly like a self-hosted label nothing carries, which is the subject of actions/runner#3081 and of the community discussion titled "Typo in runs-on results in indefinitely waiting for a runner to pick up workflow". So a hosted label is a candidate here, not one you rule out because the job did not fail.
If the set is genuinely carried by an online idle runner, the problem is one of the three gates after this one.
# every label must exist on one online runner
runs-on: [self-hosted, linux, x64]
# list what your runners actually registered
gh api /repos/{owner}/{repo}/actions/runners --jq '.runners[] | {name, status, busy, labels: [.labels[].name]}'Common causes
No online runner carries every requested label
The requested labels line is an AND, and one unmatched entry is enough. A custom label never passed at registration, a label removed when a machine was reconfigured, or an extra label added to the workflow last week all produce a set that queues silently rather than failing.
You restarted the runner and the job is still queued, because the runner was never the problem
This is where most of the time goes. A runner that shows Idle and carries the right labels can still be unreachable for this job: it may sit in a runner group that has not been given access to this repository, it may already be at its job limit, or the job may be blocked behind an account-level concurrency ceiling that has nothing to do with runners at all. Restarting a healthy machine changes none of those, and it is the first thing everyone tries.
You are at a concurrency ceiling
The cap is per account plan and counts every job you are running: 20 on Free, 40 on Pro, 60 on Team, 500 on Enterprise Cloud. A large matrix, several pull requests at once or a scheduled workflow overlapping a push can put you over it. The surplus queues and clears on its own, which looks like a runner problem until the queue drains.
Billing or a spending limit is blocking allocation
Exhausted included minutes on a private repository, a spending limit already reached, or a failed payment method stop hosted runners being allocated. Jobs queue rather than fail, so the symptom arrives with no explanation and often on a date rather than after a change.
How to fix it
Diff the requested labels against what your runners registered
- Copy the requested labels line out of the queued job.
- List the runners the repository can see and their labels, with their status and whether they are busy.
- Match the sets exactly, including case. Fix the workflow or add the missing label to the runner, whichever is wrong.
gh api /repos/{owner}/{repo}/actions/runners \
--jq '.runners[] | "\(.name) \(.status) busy=\(.busy) \([.labels[].name] | join(","))"'Rule out the concurrency ceiling with the API, not with a guess
Count what you are running before concluding anything about runners. At your plan cap the queue is behaving correctly, and the fix is fewer simultaneous jobs: narrow the matrix, add a path filter, or stagger overlapping schedules.
gh api "/repos/{owner}/{repo}/actions/runs?status=in_progress" --jq '.total_count'
gh api "/orgs/{org}/actions/runners" --jq '[.runners[] | select(.busy)] | length'Check billing when the queue started on a date rather than on a change
Look for an exhausted minute balance, a reached spending limit or a failed payment method. Two minutes, and nothing in the run will ever hint at it.
Give the queue a bound so it fails instead of hanging
A job-level timeout-minutes does not shorten a queue wait, so the practical bound is an alert on queued duration, or a scheduled check that cancels runs older than your tolerance.
gh run list --status queued --json databaseId,createdAt \
--jq '.[] | select((now - (.createdAt|fromdate)) > 1800) | .databaseId' \
| xargs -r -n1 gh run cancelThe three gates after the labels
Runner groups are the quietest one. A self-hosted runner in an organization group that was never granted access to this repository is online, idle, correctly labeled and unavailable, and nothing in the run says so. Check the group's repository access before you touch the runner itself, and grant it rather than pushing a new commit.
Concurrency is the next. GitHub caps total concurrent jobs per account plan: 20 on Free, 40 on Pro, 60 on Team and 500 on Enterprise Cloud, with a separate ceiling of 1,000 concurrent larger-runner jobs on Team and Enterprise. A matrix that fans out past the cap does not fail; the surplus queues until a sibling finishes.
Billing is the last and the most abrupt. A spending limit reached, a failed payment method or exhausted minutes leaves hosted jobs queued rather than failing, which is why an unexplained queue on the first of the month is worth checking early.
What a managed runner changes about queueing
Latchkey runners are provisioned per job rather than kept online, so the first gate is the only one shaped like the list above. A job labeled for a Latchkey runner is handed to a warm machine or a fresh one is launched, which the provisioning documentation puts at seconds for a warm pickup and about ten seconds for a cold start, and there is no fleet whose status column you keep green. There is a ceiling of its own: twenty concurrently busy runners per workspace by default, idle warm runners not counting against it, and overflow jobs queueing until a slot frees.
That removes the failure mode where a machine you own is the reason nothing starts. It does not remove queueing, and the documentation is direct about it: a job can still sit in queued because the repository is not monitored, because managed runners are not enabled, because a custom runner image is still building, because the label has a typo, or because runner launches are blocked on billing. There is no explicit error in the GitHub UI for any of those, which is the same problem this page is about, with a shorter list.
How to prevent it
- Keep a short, documented set of runner labels for the organization, and validate
runs-onwith actionlint in CI. - Grant runner groups access at the organization level rather than per repository, so a new repository is not born unable to run anything.
- Alert on queued duration, not just on failure, so a stuck job is noticed in minutes.
- Watch minutes and spending limits on a schedule, because the queue is the only symptom they produce.
- Prefer runners that are provisioned per job when nobody on the team wants to own a fleet.
Frequently asked questions
Why is my GitHub Actions workflow stuck in the "queued" state?
runs-on, the runner that does sits in a group this repository cannot use, you are at your plan's concurrent job ceiling, or billing is blocking allocation. None of the four annotates the run, so check them in that order.How long will a queued job wait before it is cancelled?
Does a queued job use any of my Actions minutes?
The runner shows as idle and my job still queues. What am I missing?
Related guides
References
- GitHub Actions limits: concurrent jobs per plan and the 24 hour queue cancellation
- actions/runner#3609: self-hosted runner stuck on "Waiting for a runner to pick up this job"
- actions/runner#3081: a typo in runs-on waits indefinitely instead of failing
- GitHub community discussion #86984: "Typo in runs-on results in indefinitely waiting for a runner to pick up workflow"
- Latchkey documentation: how provisioning works, warm pools and why a job might wait
- GitHub Actions documentation