# Speed up GitHub Actions by avoiding unnecessary work

> Speed up GitHub Actions by avoiding unnecessary work: cancel superseded runs, skip jobs rather than workflows, and cap the ones that hang.

Source: https://latchkey.dev/learn/speed/skip-unnecessary-work-in-github-actions  
Updated: 2026-09-21

You speed up GitHub Actions by avoiding unnecessary work long before you speed it up by buying a faster machine, because the fastest job is the one that never starts. Three mechanisms do nearly all of it: cancelling runs a newer push already replaced, skipping jobs rather than whole workflows, and capping the ones that hang.

Most speed work on Actions is spent making a job faster. The larger number is usually next to it: jobs that ran when they did not have to, runs that were already obsolete when they started, and a step that hung for six hours because nobody set a limit.

None of the four mechanisms below needs a new tool or a vendor. All of them are keys in the workflow file or, in one case, a line in a commit message. Each has a trap that is worth knowing before you adopt it.

## Cancel the runs a newer push already replaced

Push three times in ten minutes and by default you have three runs in flight, two of which are testing code nobody will merge. The `concurrency` key fixes that: GitHub documents it as ensuring that only a single job or workflow using the same concurrency group will run at a time, and `cancel-in-progress: true` extends that to cancelling the one already running rather than only the one waiting.

The trap is the group name, and it is a good trap because it fails quietly and across workflows. Concurrency group names are repository-wide and case insensitive, so two workflows that both use a group of `ci` will cancel each other. If you build the group out of a property that only exists for some events, such as `github.head_ref` on pull requests, you need a fallback, or the expression resolves to nothing and every run collapses into one group.

```.github/workflows/ci.yml
concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true
```

> Both halves are from the documented examples. `github.workflow` keeps the group unique to this workflow; `github.run_id` is the fallback GitHub recommends because it is guaranteed to be both unique and defined for the run, which means a run that has no head ref cancels nothing else.

## Queue instead of cancelling, where cancelling is wrong

Cancelling is right for tests and wrong for deployments, because a cancelled deploy leaves you not knowing what is running. The same key covers that case: GitHub documents a `queue` property, where `single` is the default and at most one run may be pending, and `max` allows up to 100 runs to wait in the group, with anything beyond that cancelled.

One combination is rejected outright rather than resolved: `queue: max` cannot be combined with `cancel-in-progress: true`, and the documentation states that it results in a workflow validation error. That is the right failure, since the two options describe opposite handling of a run already in progress, but it is worth knowing before you paste both into a deploy workflow.

| Setting | What happens to the run already going | What happens to the ones waiting |
| --- | --- | --- |
| No `concurrency` key | Keeps running | All run, in parallel |
| `concurrency` with the default `queue: single` | Keeps running | At most one waits; a newer one replaces it |
| `cancel-in-progress: true` | Cancelled | At most one waits; a newer one replaces it |
| `queue: max` | Keeps running | Up to 100 wait in order; the rest are cancelled |
| `queue: max` plus `cancel-in-progress: true` | Rejected | Workflow validation error |

## The commit-message skips, and what they cost

GitHub documents five strings that stop a `push` or `pull_request` workflow from being triggered at all when they appear in the commit message, plus a trailer form. They are convenient for a typo fix and they carry the same cost as a path filter: a workflow that never ran reports no check, so a required check stays pending and the pull request will not merge.

The documentation says as much and gives the only way out: push a new commit to the pull request without the skip instruction in the commit message. Note also that the skip only applies to `push` and `pull_request`, so adding `[skip ci]` will not stop a workflow triggered on `pull_request_target`, which is the one most likely to hold privileges.

- `[skip ci]`
- `[ci skip]`
- `[no ci]`
- `[skip actions]`
- `[actions skip]`
- or a `skip-checks: true` trailer at the end of the message, after two empty lines

> The five bracketed strings and the trailer form are quoted from the GitHub Docs page on skipping workflow runs, read 2026-09-21. Skip instructions apply only to the run or runs that the commit carrying them would have triggered.

## Skip a job, not a workflow

This is the single most useful distinction in the area, and it is documented in one line: a job skipped by a conditional reports "Success". A workflow skipped at the `on:` block reports nothing. So every mechanism that keeps the workflow running and decides inside it is compatible with required checks, and every mechanism that stops the workflow is not.

That makes `if:` the default tool rather than the fallback. It reads the `github` context, so a draft pull request, a bot author, a label or a commit message are all available without an action. The cost of a skipped job is a queue slot and a few seconds, not a runner minute, and what you get back is a check that reports on every pull request. Where the decision depends on which files changed, that needs a real diff, which is [what path filters do and where they break](/learn/speed/github-actions-path-filters-skip-unchanged).

```.github/workflows/ci.yml
jobs:
  e2e:
    # draft pull requests get unit tests only; the expensive suite waits for review
    if: github.event.pull_request.draft == false
    runs-on: latchkey-medium
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v5
      - run: npm ci
      - run: npm run test:e2e
```

## Cap the job that hangs

The last piece of unnecessary work is the job that is not doing anything. GitHub documents `timeout-minutes` as the maximum number of minutes to let a job run before it is automatically cancelled, with a default of 360. Six hours is not a limit anyone chose for their test suite; it is what you get by not choosing.

Set it to something a little above your realistic worst case, per job rather than per workflow, because an integration job and a lint job have nothing in common. The effect is not subtle on a matrix: one hung leg at the default holds a concurrency slot for six hours while everything else in the organization queues behind it. The documentation also notes that if your timeout exceeds the runner's own execution limit, the job is cancelled when that limit is met instead.

## Where to start, and what to measure

In order of how much they usually return: concurrency cancellation first, because it costs one key and removes whole runs; then `timeout-minutes` on every job, because it is cheap insurance against a tail you cannot see; then conditional jobs, which need thought about what should still report; then commit-message skips, which are a manual tool rather than a policy.

Measure before and after on runs rather than on minutes. The number that moves first is how many runs started, not how long each one took, and a bill that falls while every job is exactly as slow as before is the signal that this worked. The per-job speed levers are a different page: [why GitHub Actions is slow](/learn/speed/why-is-github-actions-slow) walks the timeline that tells you which of the two you have.

## What this page does not measure

Nothing here was reproduced on a runner and no timing on this page comes from one, because the saving is in runs that did not happen, and a harness can only record runs that did. Counting the difference honestly means a controlled week of real pull requests on a real repository with and without each key, which is an experiment nobody can run twice on the same traffic.

So every claim above is a documented behavior, quoted from the reference that defines it, and the sizes of the savings are left to your own numbers. The two you can pull today from workflow history are the share of runs that were cancelled or superseded, and the longest job in the last month against the timeout you have set for it.

## FAQ

### How do I cancel an old GitHub Actions run when a new commit is pushed?

Add a `concurrency` block with `cancel-in-progress: true` and a group that is unique to the workflow and the branch, such as `${{ github.workflow }}-${{ github.head_ref || github.run_id }}`. Group names are repository-wide and case insensitive, so without the workflow name in the group two different workflows will cancel each other.

### What are the commit message strings that skip a GitHub Actions run?

GitHub documents five: `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` and `[actions skip]`, plus a `skip-checks: true` trailer placed at the end of the message after two empty lines. They apply only to `push` and `pull_request`, and a workflow they skip leaves its required checks pending, which blocks the merge until you push a commit without the instruction.

### What is the default timeout for a GitHub Actions job?

360 minutes. `timeout-minutes` is documented as the maximum number of minutes before the job is automatically cancelled, and six hours is the value you inherit by not setting one. If the timeout you set exceeds the runner's own execution limit, the job is cancelled when that limit is reached instead.

### Should I skip the workflow or skip the job?

Skip the job, unless nothing requires the check. A job skipped by a conditional reports "Success", so required checks are satisfied, while a workflow skipped at the `on:` block reports nothing at all and leaves the pull request waiting. The workflow-level filter is cheaper, since no runner is allocated, and it is only safe where no branch rule names that check.

## References

- [GitHub Docs: workflow syntax, concurrency, the queue property and timeout-minutes (verified 2026-09-21)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Docs: skipping workflow runs, the five strings and the skip-checks trailer (verified 2026-09-21)](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs)
- [GitHub Docs: controlling jobs with conditions (verified 2026-09-21)](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-jobs-with-conditions)
- [GitHub Docs: troubleshooting required status checks, and what a skipped run reports (verified 2026-09-21)](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks)

---

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
