What does timeout-minutes do in a GitHub Actions job, and what is its default?
answer
- a ceiling, not a target
- the default is measured in hours
- six of them
- also valid one level down
- a hang becomes a definite failure
basics
~20 stimeout-minutes caps how long a GitHub Actions job may run before the runner terminates it and the job is reported as failed. The default is 360 minutes, and the key can also be set on an individual step.
solid answer
~50 s`timeout-minutes` bounds execution. At job level it defaults to **360 minutes** — six hours — which in practice means a hung job burns a runner for most of a working day before anyone notices. Setting it to something realistic, say 20 for a unit-test job, converts a hang into a fast, unambiguous red build. When the limit is hit the job is terminated and reported as failed, not skipped and not retried. The same key is valid on a single step, which is the better tool when only one step can plausibly hang — a flaky network fetch or an integration test — because it lets later steps such as diagnostics and teardown still run under `if: always()`. On GitHub-hosted runners a job is additionally subject to the platform's own six-hour execution cap, so a larger `timeout-minutes` does not buy more time there.
code
yaml · 12 linesjobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 25 # backstop for the whole job
steps:
- uses: actions/checkout@v4
- run: docker compose up -d --wait
timeout-minutes: 5 # the health check can hang
- run: ./gradlew integrationTest
timeout-minutes: 15
- if: always()
run: docker compose down -vgo deeper
Recall that timeout-minutes caps how long a GitHub Actions job runs and that the default is 360 minutes. Know that exceeding it fails the job rather than skipping it.
Explain why the six-hour default is dangerous for a hung job, that the key also exists at step level, and that a timed-out step fails so later steps need if: always() to still run.
Choose values from observed durations rather than guesses, combine a tight step bound with a looser job backstop, and explain why a job-level timeout cannot leave room for teardown. Distinguish queue time from execution time when diagnosing slowness.
Set the organisational default: whether every job template ships with an explicit timeout, how flakiness caused by too-tight limits is detected, and what a hung self-hosted runner costs the shared queue.
## What the key does `timeout-minutes` is a ceiling on wall-clock execution. It appears in two places: ``` jobs: test: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: Integration tests timeout-minutes: 12 run: ./gradlew integrationTest ``` At **job** level it limits the whole job. At **step** level it limits that one step. When the limit is reached the work is terminated and the result is a **failure** — not a skip, not a retry, and nothing is automatically re-run. ## The default that surprises people The job-level default is **360 minutes**. Six hours. Almost no CI job legitimately needs that, and the default exists to accommodate the rare long build rather than to describe a typical one. The consequence is the reason this question gets asked. A test that deadlocks, a script that waits forever on a prompt, a `docker compose` health check that never goes healthy — none of these fail. They hang, holding a runner. On GitHub-hosted runners you pay for that time; on self-hosted runners you starve every other job in the queue behind it. A job that would normally finish in four minutes sitting at three hours is not a mystery, it is the default doing exactly what it was configured to do. There is also a platform limit behind the setting: on GitHub-hosted runners each job is capped at six hours of execution regardless of what you write, so raising `timeout-minutes` above 360 does not extend a hosted job. ## Job level or step level? Use **step level** when one specific operation is the plausible hang and you want everything after it to still happen. Because a timed-out step fails, the steps after it are skipped unless they carry `if: always()` or `if: !cancelled()` — so the useful pattern is a bounded risky step plus unconditional diagnostics and teardown: ``` - name: Integration tests timeout-minutes: 12 run: ./gradlew integrationTest - name: Logs if: failure() run: docker compose logs --no-color > compose.log - name: Tear down if: always() run: docker compose down -v ``` Use **job level** as the backstop for everything the step-level bounds do not cover — setup, cache restore, an action that hangs. A job-level timeout ends the job outright, which means teardown steps do not get a chance to run; that is a real limitation and the reason the step-level bound is the more surgical instrument. Most mature pipelines use both: a generous job-level cap that is still far below six hours, and tight caps on the two or three steps known to be able to hang. ## How to pick a number Look at the job's actual duration over recent runs and set the timeout well above the slow tail but far below "nobody will notice". A common rule of thumb is roughly two to three times the observed p95 — enough headroom that a cold cache or a slow runner does not produce a false red, tight enough that a hang is caught in minutes. Revisit it when the job grows; a timeout that starts firing on healthy runs is worse than no timeout, because the team learns to re-run red builds reflexively. ## What it is not It is not a queue-time limit. Time spent waiting for a runner is not execution, and `timeout-minutes` does not start counting until the job begins running. It is not cancellation-safe cleanup. A timed-out job is over; only step-level timeouts leave room for the remaining steps to react. It is not a retry mechanism. If a job's flakiness needs bounded retries, that is a separate decision about re-running the job, and a timeout only turns an infinite hang into a definite failure so that such a decision is possible at all.
- In GitHub Actions, when would you set timeout-minutes on a step instead of on the job?When one specific step is the plausible hang and later steps must still run. A step-level timeout fails that step, so diagnostics guarded by `if: failure()` and teardown guarded by `if: always()` still execute. A job-level timeout ends the job outright and takes those steps with it, so it works better as an outer backstop than as the primary bound.
- What status does a GitHub Actions job get when it exceeds timeout-minutes?It is terminated and reported as failed, and dependent jobs that rely on it are skipped by default. There is no automatic retry and no separate timed-out status in the dependency graph, so `needs.<job_id>.result` reads `failure`. If a downstream notification job must still run, it needs `if: always()` or `if: failure()` on top of its `needs:` edge.
- Does timeout-minutes include the time a GitHub Actions job spends waiting for a runner?No. It bounds execution once the job has started on a runner; queue time is separate. That distinction matters when diagnosing a slow pipeline: a job that sat in the queue looks slow on the run page but never trips its timeout, so the fix is runner capacity or labels rather than a larger timeout value.
saying these in an interview costs you the question
- Assumes jobs have no timeout unless you add one
- Guesses the default is 60 minutes
- Thinks a timed-out job is retried automatically
- Expects teardown steps to run after a job-level timeout
- Believes queue time counts toward the limit