# Waiting for a runner to pick up this job in GitHub Actions

> Fix a GitHub Actions job stuck on "Waiting for a runner to pick up this job": match the labels, check the group, the concurrency ceiling and billing.

Source: https://latchkey.dev/learn/failures/job-stuck-queued-waiting-for-runner  
Updated: 2026-09-20

"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.

```Actions log, queued job (the shape GitHub prints; see actions/runner#3609)
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...
```

## 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

1. Copy the requested labels line out of the queued job.
2. List the runners the repository can see and their labels, with their status and whether they are busy.
3. Match the sets exactly, including case. Fix the workflow or add the missing label to the runner, whichever is wrong.

```Terminal
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.

```Terminal
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.

```Terminal
gh run list --status queued --json databaseId,createdAt \
  --jq '.[] | select((now - (.createdAt|fromdate)) > 1800) | .databaseId' \
  | xargs -r -n1 gh run cancel
```

## How to prevent it

- Keep a short, documented set of runner labels for the organization, and validate `runs-on` with 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.

## 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.

```Terminal
# 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]}'
```

## The 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](/documentation/runner-provisioning) 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.

## FAQ

### Why is my GitHub Actions workflow stuck in the "queued" state?

Because no runner has matched it yet, and four things can cause that: no online runner carries every label in `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?

A job waiting for a self-hosted runner is automatically cancelled after 24 hours in the queue. A job waiting behind a concurrency ceiling on hosted runners starts as soon as capacity frees, so it has no fixed bound. Neither produces a useful annotation while it waits.

### Does a queued job use any of my Actions minutes?

No. Minutes are metered while a job runs, so queue time is not billed. What it costs is wall-clock time on a pull request and a required check that stays pending, which is why an alert on queue duration is worth more than the billing question.

### The runner shows as idle and my job still queues. What am I missing?

Usually the runner group. A runner can be online, idle and correctly labeled while sitting in a group that was never granted access to the repository requesting it, and nothing says so. The other two candidates are a label that differs by case or spelling, and a concurrency ceiling you have already reached.

## References

- [GitHub Actions limits: concurrent jobs per plan and the 24 hour queue cancellation](https://docs.github.com/en/actions/reference/limits)
- [actions/runner#3609: self-hosted runner stuck on "Waiting for a runner to pick up this job"](https://github.com/actions/runner/issues/3609)
- [actions/runner#3081: a typo in runs-on waits indefinitely instead of failing](https://github.com/actions/runner/issues/3081)
- [GitHub community discussion #86984: "Typo in runs-on results in indefinitely waiting for a runner to pick up workflow"](https://github.com/orgs/community/discussions/86984)
- [Latchkey documentation: how provisioning works, warm pools and why a job might wait](/documentation/runner-provisioning)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
