A job hits a timeout, or hangs until GitHub kills it

A GitHub Actions job that times out is either genuinely too slow for its bound, or it is stuck waiting on something that never arrives, a fixed sleep, an unready container, a queue backlog. Read the run's own timing and its log tail before assuming the job itself is at fault.

hybrid · repo + 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

Every GitHub Actions job carries a timeout: an explicit timeout-minutes you set, or the platform default of 360 minutes if you never set one. When a job is cancelled for exceeding it, the run log shows a distinct line, the job running on the runner has exceeded the maximum execution time, which tells you the limit fired but not why the job was still running. That distinction matters because a job hits the wall for two very different reasons: it is doing real, unbounded work that legitimately grew past whatever bound was set, or it is not doing work at all, it is blocked on a fixed sleep, an unhealthy service container, or a queue slot behind an earlier, now-obsolete run of the same workflow. The order below checks the cheapest, most common causes first: whether a timeout is set at all and at which level, whether the job is waiting on time instead of on a real signal, whether a container's readiness gate is missing, whether the job's own work has grown too large for one runner, and only then whether the run was ever going to finish before something newer superseded it.

  • The run log ends with "The job running on runner ... has exceeded the maximum execution time" and the job is marked cancelled, not failed.

  • A job that used to finish in minutes now runs for hours before GitHub cuts it off.

  • The job's log shows long stretches with no new output, then a burst of activity right before the timeout, or none at all.

  • You never set timeout-minutes on this job, and it is running close to or past 360 minutes when it gets killed.

  • Several runs of the same workflow are queued or running at once for the same branch, and the oldest one is still going when a newer push lands.

How to diagnose it

  1. Open the timed-out run in the Actions UI and read the last log line: "The job running on runner ... has exceeded the maximum execution time" confirms the platform's own timeout fired rather than a step failing on its own.

  2. Check whether the job was actually running or still queued when it hit the wall, the Actions UI shows queued time separately from execution time; a long queued time points at the concurrency cause, not the job's own work.

  3. grep -n 'timeout-minutes' .github/workflows/*.yml to see whether this job has an explicit bound, and at what value, versus running on the 360-minute default. Do not window the search around runs-on: timeout-minutes sits among the job's other keys and is routinely further than a line or two away, so a windowed grep reports it absent on jobs that do set it.

  4. Scan the log for the last step that produced output before the gap. A step that goes silent for minutes with no further lines is very likely blocked on a fixed sleep or an unready dependency, not doing real work.

  5. If a services: block backs the job, check its options for --health-cmd; its absence combined with a sleep step right after is the container-healthcheck cause.

  6. Compare this job's step timings across the last 10 to 20 runs in the Actions UI. A steady climb toward the timeout value across runs is the long-running-job cause; a flat history until one anomalous run is more likely a hang.

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. No timeout-minutes set, so the job runs to GitHub's 360-minute default

    If a job definition has no timeout-minutes key, GitHub applies a default of 360 minutes before cancelling it, per the workflow syntax reference. That default exists at the job level; an individual step has no default timeout of its own and can run for the remainder of the job's budget unless you cap it separately with a step-level timeout-minutes (also capped at 360). A job with no explicit bound looks fine on every normal run and only reveals the problem when something hangs, at which point it occupies a runner for up to six hours before anyone finds out.

    Confirm it

    grep -c 'timeout-minutes' .github/workflows/*.yml against the number of jobs in each file; a job with no timeout-minutes key of its own is running on the 360-minute default. Count over the whole file rather than a window around runs-on - the key is not required to sit within a line or two of it, so a windowed count returns zero for almost every repository, correctly bounded ones included, and would confirm this cause everywhere.

    Fix: Job timeouts
  2. A step is waiting on a fixed sleep instead of a real readiness signal

    A fixed sleep N, page.waitForTimeout, cy.wait(ms), or a hand-rolled polling loop with no exit condition all wait on the clock, not on the thing actually becoming ready. When the dependency comes up faster than the sleep, time is wasted every run; when it comes up slower, or never, on a bad run, the step blocks past its wait and the job keeps going with nothing to show for it until the job timeout eventually cuts it off. This is the most common shape of a job that looks like it hung: no error, no progress, just silence until the platform kills it.

    Confirm it

    grep -rn 'sleep [0-9]\|waitForTimeout(\|cy\.wait([0-9]' .github/workflows/ tests/ e2e/ --include='*.yml' --include='*.ts' --include='*.js'; a match on the step that stalls confirms this cause.

    Fix: Polling waits, not fixed sleeps
  3. A service container has no healthcheck, so the workflow waits on a fixed sleep for it

    A job that starts a database or cache as a services: container but gives it no options: --health-cmd has no way to know when that container can accept connections, so workflows commonly paper over this with a sleep 10 before the first query. On a slow or contended runner the container is not actually ready when the sleep ends, and the step that depends on it either fails or, if it retries in a loop with no bound, hangs until the job's own timeout fires.

    Confirm it

    grep -A10 'services:' .github/workflows/*.yml | grep -c 'health-cmd' against the number of services: blocks; a service container block with no --health-cmd in its options is running unhealthchecked.

    Fix: Container healthchecks
  4. One job is doing more work than fits comfortably inside its timeout

    A single job that runs an entire test suite, or a full build plus every integration test, sequentially in one runner has no ceiling on how much its own workload can grow as the codebase does. It can also show up as one specific step or the cache-save Post step stretching far past what the rest of the job takes, both symptoms of work concentrated in one place instead of spread across the job graph. Either way the job's real duration creeps toward its timeout over months, until an ordinary slow day is enough to tip it over.

    Confirm it

    Open the job's step timings in the Actions UI for several recent runs and compare the slowest step's duration to the job's timeout-minutes; a step whose duration or variance is climbing toward that ceiling, or a Post step alone taking real minutes, means the job's workload needs to be split rather than the timeout raised.

    Fix: Long-running jobs split
  5. Superseded runs are never cancelled, so old runs queue behind each other and time out waiting for a runner

    A workflow with no top-level concurrency: block, or one with cancel-in-progress: false, lets every push start its own run without stopping the run for the commit it just replaced. On a branch with several pushes in quick succession this backs up: earlier runs keep occupying or queuing for runners well after their result stopped mattering, and a run that is queued rather than executing can sit long enough to look identical to a hung job in the Actions UI, even though nothing in the job itself is broken.

    Confirm it

    grep -L 'concurrency:' .github/workflows/*.yml for workflows that trigger on push or pull_request, and separately grep -rn 'cancel-in-progress: *false' .github/workflows/; either match on a workflow with multiple runs per branch confirms this cause.

    Fix: Superseded runs cancelled

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 why a job in this repository's GitHub Actions workflows times out or hangs until GitHub cancels it. Do not change anything yet; report findings first.

1. Read the job's `timeout-minutes` at job and step level. If neither is set, it runs on GitHub's 360-minute default; note this as a candidate.
2. Search the workflow and its test sources for fixed waits: `sleep N`, `waitForTimeout(`, `cy.wait(ms)`, or an unbounded polling loop. A match on the step where the run appears to stall is a strong candidate.
3. If the job starts a `services:` container, check its `options` for `--health-cmd`. No healthcheck plus a `sleep` step right after is the same class of cause.
4. Pull the job's step timings for the last 10-20 runs. A step whose duration or variance climbs toward the timeout means the job's workload has outgrown one runner.
5. Check for a top-level `concurrency:` block with `cancel-in-progress: true`. Its absence on a workflow with multiple runs per branch means old runs may be queuing, not hanging.

Report which cause you found, quoting the relevant log line or YAML, before proposing or making any change.

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

1GitHub Actions workflow syntax: timeout-minutes (opens in new tab)

2GitHub Actions: usage limits (job execution time) (opens in new tab)

3GitHub Actions: control workflow concurrency (opens in new tab)

4GitHub Actions: create Redis service containers (healthcheck options) (opens in new tab)

Last updated 2026-08-31