---
title: "Why GitHub Actions Jobs Time Out | StarSling"
description: "Diagnose why a GitHub Actions job times out or hangs: no timeout-minutes, fixed sleeps, missing healthchecks, an oversized job, or stacked-up superseded runs."
url: https://starsling.dev/github-actions/problems/github-actions-timeouts
canonicalUrl: https://starsling.dev/github-actions/problems/github-actions-timeouts
---

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

[GitHub Actions](https://starsling.dev/github-actions) / [Problems](https://starsling.dev/github-actions/problems) / A job hits a timeout, or hangs until GitHub kills it

- [How StarSling works](https://starsling.dev/)

Diagnosis mode: hybrid. Last updated: 2026-08-31

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.

## Table of contents

- [Symptoms](#symptoms)
- [How to diagnose it](#how-to-diagnose-it)
- [Likely causes](#likely-causes)
- [Hand it to an agent](#verify)
- [Related pages](#related-pages)
- [Related symptoms](#related-symptoms)
- [Sources](#sources)

<a id="symptoms"></a>

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

<a id="how-to-diagnose-it"></a>

## How to diagnose it

[ci-speedup](https://starsling.dev/ci-speedup) carries the detection logic behind the causes below and opens the fix as a reviewable pull request. Install it with `npx skills add starslingdev/skills`, then run `/ci-speedup` in your repository.

To check by hand:

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.

<a id="likely-causes"></a>

## 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](https://starsling.dev/best-practices/github-actions/bound-job-timeouts) (`ci.hygiene.job-timeouts`, detection mode static)

### 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](https://starsling.dev/best-practices/github-actions/replace-fixed-sleeps-with-polling) (`ci.hygiene.polling-waits`, detection mode static)

### 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](https://starsling.dev/best-practices/github-actions/wait-for-container-healthchecks) (`ci.hygiene.container-healthchecks`, detection mode static)

### 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](https://starsling.dev/github-actions/optimizations/split-long-running-jobs) (`ci.parallel.long-running-jobs`, detection mode runtime)

### 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](https://starsling.dev/best-practices/github-actions/cancel-superseded-runs) (`ci.trigger.cancel-superseded`, detection mode hybrid)

<a id="verify"></a>

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

<a id="related-pages"></a>

## Related pages

- [Bound GitHub Actions runner jobs with timeout-minutes](https://starsling.dev/best-practices/github-actions/bound-job-timeouts)
- [Replace fixed CI sleeps with bounded readiness polling](https://starsling.dev/best-practices/github-actions/replace-fixed-sleeps-with-polling)
- [Wait for container healthchecks instead of sleeping](https://starsling.dev/best-practices/github-actions/wait-for-container-healthchecks)
- [Split long GitHub Actions jobs into parallel work](https://starsling.dev/github-actions/optimizations/split-long-running-jobs)
- [Cancel superseded runs with concurrency and cancel-in-progress](https://starsling.dev/best-practices/github-actions/cancel-superseded-runs)
- [Why GitHub Actions is slow, and how to fix it](https://starsling.dev/github-actions-too-slow)

<a id="related-symptoms"></a>

## Related symptoms

Other symptoms on this site that share a likely cause with this one. If none of the causes above is yours, one of these is usually the page you wanted.

- [CI fails, then passes on an unchanged rerun](https://starsling.dev/github-actions/problems/flaky-tests)
- [A GitHub Actions job stays queued or hangs with no output](https://starsling.dev/github-actions/problems/github-actions-jobs-stuck)
- [Why your test job is slower in CI than it is locally](https://starsling.dev/github-actions/problems/slow-tests)

<a id="sources"></a>

## Sources

- [GitHub Actions workflow syntax: timeout-minutes](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Actions: usage limits (job execution time)](https://docs.github.com/en/actions/reference/limits)
- [GitHub Actions: control workflow concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency)
- [GitHub Actions: create Redis service containers (healthcheck options)](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-redis-service-containers)
