Skip to content
Latchkey LogoLatchkey home

GitHub Actions job timed out

A GitHub Actions job timed out means a clock fired, not that anything threw: either the timeout-minutes you set, the six hour ceiling GitHub puts on a hosted job, or a limit inside the command itself. Find out which of the three ended the job before you change a number, because raising the wrong one costs you another full run to discover it.

Runner log: a step waiting on a lock, exiting 124, then finishing on the retry
The recorded run: the step waits out its thirty second limit and exits 124, the runner retries it, and the second attempt applies the migrations because the script cleared its own lock marker between attempts.
Diagram of the three timeouts that can end a job and which one each message names
Three clocks, one message. The innermost one that fires is the one that ends the step, and it is usually not the one you configured.

What this error means

The step stops mid-output with no error from the command and the log ends on "Error: The operation was canceled." A job that hit the platform ceiling is annotated with the limit it passed, in the form "The job running on runner <name> has exceeded the maximum execution time of 360 minutes", and 360 is whatever the effective limit was. A command that enforced its own deadline reports a third way: GNU timeout exits 124, curl reports 28, and most test runners print their own message and exit 1. The three read almost identically in the log and have completely different fixes, so the first job is telling them apart.

Actions log, migration step
waiting for the schema lock to be released...
waiting for the schema lock to be released...
timeout(1) exited 124

Reproduced on a Latchkey runner

Run 2026-09-20·Runner latchkey-small·Exit code 0

waiting for the schema lock to be released...
waiting for the schema lock to be released...
waiting for the schema lock to be released...
waiting for the schema lock to be released...
waiting for the schema lock to be released...
waiting for the schema lock to be released...
timeout(1) exited 124
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
migrations applied
timeout(1) exited 0

Which clock fired: read the elapsed time first

The duration in the job header is the diagnosis. If it matches the number you put in timeout-minutes, that is the clock. If it ran for six hours, that is GitHub's ceiling: each job on a GitHub-hosted runner "can run for up to 6 hours of execution time", and timeout-minutes defaults to 360 minutes for a job, while for a step 360 is the maximum rather than a default, so a job with no timeout and a hang in it always ends at six hours.

If it matches nothing you configured, the limit came from inside the command. Read the last line the command printed and the first line after it; a tool that enforced its own deadline almost always says so on the way out.

Self-hosted changes two of the three numbers and not the third. A job on a self-hosted runner "can run for up to 5 days of execution time", and a whole workflow run is cancelled at 35 days whatever it runs on. Your own timeout-minutes still applies on top of both.

.github/workflows/ci.yml
- name: Integration tests
  timeout-minutes: 20          # the step clock; 360 is its maximum
  run: npm run test:integration -- --testTimeout=60000   # the command clock

jobs:
  test:
    timeout-minutes: 45        # the job clock; 360 by default

Common causes

The step is waiting for something that is never going to arrive

The most common timeout is a hang, not slowness. A service that never binds its port, a migration blocked behind another lock, a prompt nothing will answer, a test waiting on a webhook CI cannot receive. The step stops producing output and then stops existing, and the elapsed time is whatever limit was nearest.

You raised timeout-minutes and it timed out again at the same point

This is the reason the obvious fix so often does nothing. A limit raised at job level does not move one set on the step, neither moves a deadline inside the command, and none of them moves GitHub's six hour ceiling. If the job ends after the same duration as before, the clock you raised was not the one that fired. The other version is raising the limit on a hang, which turns a twenty minute red build into a six hour one.

The job is genuinely slower than the budget it was given

A cache that stopped restoring, a dependency install that went from two minutes to nine, a matrix leg that grew, a test suite that doubled. Nothing is hung; the number was set when the job was smaller. In our experience this arrives as "it timed out once this week and again on Friday", because the job now sits just under the limit.

Something cancelled the job and the log reads like a timeout

A concurrency group with cancel-in-progress, a fail-fast matrix or a manual cancellation all end on the same "The operation was canceled" line. The elapsed time is the giveaway: a cancellation ends at an arbitrary point, a timeout ends on a round number.

How to fix it

Identify the clock, then set the limit at the level that fires

  1. Read the elapsed time on the job and compare it against your step limit, your job limit and the six hour ceiling.
  2. Set timeout-minutes on the step that actually hangs, not only on the job, so a hang fails in minutes instead of running out the job clock.
  3. Keep a job-level limit as the backstop, sized a little above the slowest honest run rather than at the default 360.
.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7
      - name: Integration tests
        timeout-minutes: 12
        run: npm run test:integration

Give the hanging command its own deadline so it fails with a reason

A step that is cancelled tells you nothing. A command that times out on its own tells you what it was waiting for, and it does it in seconds rather than at the job limit. Wrap anything that talks to a network or a lock, and prefer the tool's own flag where it has one.

.github/workflows/ci.yml
- run: timeout 120 ./scripts/wait-for-db.sh || { echo "db never came up"; exit 1; }
- run: curl --max-time 30 --retry 3 --retry-delay 5 https://api.internal/health
- run: npx jest --testTimeout=60000 --forceExit

Find out where the time went before you buy more of it

The step durations in the run summary are the cheapest profiler you have, and a timestamped log turns a hang into a specific line. Re-run with debug logging when the log simply stops.

.github/workflows/ci.yml
# in the step that times out
- run: |
    set -x
    date -u +%H:%M:%S
    ./slow-thing
    date -u +%H:%M:%S

# or re-run with debug logging from the Actions UI,
# which sets ACTIONS_STEP_DEBUG for that run only

If it was a cancellation, scope the concurrency group instead

Raising a limit does nothing to a job that was cancelled. Scope the group so a required check is not cancelled out from under an open pull request, because a cancelled conclusion is a non-success conclusion and branch protection stays red until a run of that job completes.

.github/workflows/ci.yml
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'push' }}

Make the job smaller rather than the limit bigger

When the job is honestly slow the limit is not the problem. Restore the cache that stopped hitting, shard the suite across the matrix, skip the legs a path filter proves are unaffected, or move the job onto a runner with more cores. A job that finishes in eight minutes does not need a thirty minute limit to be safe.

.github/workflows/ci.yml
strategy:
  fail-fast: false
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx jest --shard=${{ matrix.shard }}/4

What the runner does about a timeout, and where it stops

Exit code 124 is the code GNU timeout returns "if COMMAND times out, and --preserve-status is not specified", and Latchkey's exit-code table carries it as a heal at confidence 0.92 with a plan of retry with backoff, two attempts, five seconds apart. The table's own reasoning is that on CI runners "the dominant cause of step timeouts is transient": a slow registry, a DNS hiccup, brief CPU contention.

The reproduction above is that case, with one disclosure it needs. The script creates the marker that clears its own lock between attempts, so the retry succeeds because the script's state changed and not because a real dependency recovered. What the run demonstrates is the engine's half: timeout ends the step at thirty seconds with 124, the runner retries it, and the retry is allowed to finish.

The table is equally clear about when this is useless, and the caveat is the more important half: the failure could be "a deterministic infinite loop / hung deadlock in user code, in which case retry will burn the same time and re-time-out", or "a CPU-bound task that legitimately needs more time", where the retry "won't help, but won't make things worse". A retry buys you the transient class and charges you double for the rest, which is why the fixes below start with finding out which one you have.

How to prevent it

  • Set timeout-minutes on every job. The default is 360 minutes, which is not a limit, it is a billing exposure.
  • Put a shorter limit on the one or two steps that can hang, so a hang fails in minutes with the step named.
  • Give every network call and every wait loop its own deadline, so the log says what it was waiting for.
  • Track step durations over time and move the limit when the job grows, rather than after it fails.
  • Keep cancel-in-progress off the jobs that back required checks.

Frequently asked questions

Why do GitHub Actions jobs time out?
Three clocks can end a job and the one that fires first wins: the timeout-minutes you set on the step or the job, GitHub's own ceiling of six hours per job on a hosted runner, and any deadline inside the command itself. Most real timeouts are a hang rather than slowness, so the duration usually matches a configured limit exactly rather than falling somewhere unusual.
Is it possible to run a job longer than 6 hours on a self-hosted runner?
Yes. The six hour limit applies to GitHub-hosted runners; a job on a self-hosted runner can run for up to 5 days of execution time. The limit you cannot escape either way is the workflow run, which GitHub cancels at 35 days. Your own timeout-minutes applies on top of whichever ceiling you are under.
Why is timeout-minutes being ignored?
Almost always because a different clock fired first. A limit set on the job does not shorten a limit set on a step, and neither one reaches inside a command that enforces its own deadline, so the step can end while your number is still untouched. Compare the elapsed time against each limit; the one it matches is the one that actually applies.

Related guides

References

Find which clock fired before you raise a number. Latchkey runner sizes change on the same runs-on line. Start free → 30-day trial · No credit card