Speed up GitHub Actions by avoiding unnecessary work
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.
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: trueQueue 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: truetrailer at the end of the message, after two empty lines
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.
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:e2eCap 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 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.
Frequently asked questions
How do I cancel an old GitHub Actions run when a new commit is pushed?
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?
[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?
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?
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.Related guides
References
- GitHub Docs: workflow syntax, concurrency, the queue property and timeout-minutes (verified 2026-09-21)
- GitHub Docs: skipping workflow runs, the five strings and the skip-checks trailer (verified 2026-09-21)
- GitHub Docs: controlling jobs with conditions (verified 2026-09-21)
- GitHub Docs: troubleshooting required status checks, and what a skipped run reports (verified 2026-09-21)
- GitHub Actions documentation