Skip to content

Blog

Speed up GitHub Actions by running less: concurrency, path filters, sharding

·

The cheapest job is the one that never starts, and the second cheapest is the one that stops as soon as nobody wants its answer. Most advice about a slow pipeline is about a faster machine. This post is about the other lever: fewer runs, shorter runs, and runs that stop when they stop mattering. Everything below is GitHub Actions behaviour, read from GitHub’s own documentation on 10 November 2026, with the YAML to go with it.

Stop starting the same run twice

A workflow with both a push trigger and a pull_request trigger, and no branch filter on the push, runs twice for every commit you push to a branch that has an open pull request: once for each event. Both runs test the same code. Restrict the push trigger to the branches that need it, normally the default branch, and let the pull request trigger cover everything else.

If you use a merge queue, add merge_group as a third trigger. GitHub’s documentation says a workflow without it is not run when a pull request enters the queue, so the required check is never reported and the merge fails.

name: ci on: push: branches: [main] pull_request: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v7 - run: make test

Cancel what a newer push made pointless

A concurrency group allows one running workflow run at a time. When a second run in the same group arrives it waits as pending, and by default a newer pending run replaces an older one. Adding cancel-in-progress also cancels the run that is already going. The value can be an expression, which is what lets you cancel superseded runs on pull requests and leave runs on the default branch alone: every commit on main keeps its own result.

Put the workflow name in the group. GitHub’s own example of a group built from github.head_ref alone has no workflow name in it, and its documentation warns that group names must be unique across workflows in a repository, otherwise a run of one workflow cancels a run of another. The sample above uses github.workflow and github.ref, which, unlike head_ref, is not limited to pull request events, so it needs no fallback.

Do not apply the same rule to a deploy. Cancelling a run halfway through a rollout is how you get half a rollout. The default has a smaller catch of its own: with a running deploy and several newer pushes, only the newest stays pending, so the commits in between are never deployed on their own. If each one has to go out, GitHub’s newer queue: max option lets up to 100 runs wait in order, and drops any beyond that. It cannot be combined with cancel-in-progress: true, which is a validation error, and GitHub says the order is first in, first out but not guaranteed.

name: deploy on: push: branches: [main] permissions: contents: read concurrency: group: production-deploy queue: max jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 30 environment: production steps: - uses: actions/checkout@v7 - run: npm run deploy

Path filters, and the trap that blocks merges

paths and paths-ignore stop a workflow starting when a change cannot affect it, such as a documentation edit. A few details decide whether they behave as you expect. You cannot use both on the same event. GitHub compares a pull request with a three-dot diff and a push with a two-dot diff. Path filters are not evaluated for tag pushes. And the filter fails open in two places: a push of more than 1,000 commits, or a diff that times out, always runs the workflow, and when a diff holds more than 3,000 files only the first 3,000 are compared.

The trap is in what a skipped workflow reports. GitHub’s documentation says that if a workflow is skipped because of path filtering, branch filtering or a commit message, its checks stay in a Pending state, and a pull request that requires them is blocked from merging. Nothing has failed. The check simply never arrives. Its suggested fix, in the table for skipped but required checks, is to avoid requiring workflows that can be skipped.

The usual way to do that is a gate. Keep the workflow free of paths: so it always starts, decide inside it which jobs are worth running, and make one final job the only required check. The same table explains the two details that make the gate work. A job skipped by a conditional reports success, so skipping is safe. But a job that depends on a failed job is itself skipped and may not block the merge, so the gate needs always() and has to read its dependencies’ results itself, which is what the loop below does.

The changes job below uses dorny/paths-filter, a widely used third-party action. On a pull request it lists the changed files through the API, which is why it asks for pull-requests: read. In your own repository, pin it to a full commit SHA rather than the v4 tag. Include .github/workflows/** in the filter, so that a change to the pipeline itself is tested by the pipeline.

name: ci on: pull_request: merge_group: push: branches: [main] permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: changes: runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read pull-requests: read outputs: code: ${{ steps.filter.outputs.code }} steps: - uses: actions/checkout@v7 - uses: dorny/paths-filter@v4 id: filter with: filters: | code: - 'src/**' - 'package*.json' - '.github/workflows/**' test: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: lts/* cache: npm - run: npm ci - run: npm test ci-ok: if: always() needs: [changes, test] runs-on: ubuntu-latest timeout-minutes: 2 env: RESULTS: ${{ join(needs.*.result, ' ') }} steps: - name: Every job this one waited on passed or was skipped run: | for result in $RESULTS; do case "$result" in success|skipped) ;; *) echo "a needed job finished as: $result"; exit 1 ;; esac done

Then, in the branch protection rule or ruleset, require ci-ok and nothing else. The gate is a real job and is billed like one, so keep it to a few lines of shell.

Run the quick checks first

Put formatting, linting, type checks and unit tests in one job, and make the slow suites, integration tests and browser tests wait for it with needs. A typo then fails in the first few minutes and the expensive jobs never start. The cost is on green runs, where the slow jobs now start later, so the total time is longer by the length of the fast job. If the fast job is itself slow, the trade may not be worth it: run the two side by side and accept some wasted minutes in the slow job whenever the fast one fails.

fail-fast applies to a whole matrix and defaults to true: one failed leg cancels the others that are running or queued. That is the right default for a build matrix, where one red leg already means the answer is no. It is the wrong choice for shards of one test suite, where you want every failure from one run, so set it to false there.

Give every job a timeout

timeout-minutes on a job defaults to 360, six hours. A test that hangs on a network call will hold a machine, and bill for it, for all six. Set a timeout on every job, a little above the longest healthy run, and the same hang costs you the timeout instead. There is also a per-step timeout-minutes for the one step you know can stall. Every sample on this page sets one.

One thing to check if you are moving a long job between providers: runners have their own ceiling, and it can be below the six hours GitHub allows. On ours a job may run for 60 minutes, so a build that takes longer needs splitting into jobs. The limits page has the details.

Shard a long test job across a matrix

Jest takes --shard=1/4, and Playwright takes the same flag. The numbers are 1-based and the index cannot exceed the count. A matrix then runs one shard per leg. Playwright documents one caveat: it can only shard tests that can run in parallel, and by default it shards whole files. It also writes a blob report per shard on CI, and a final job downloads them and runs merge-reports to produce one report. The Playwright sharding documentation has the full workflow.

name: test on: pull_request: permissions: contents: read jobs: jest: runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: shardIndex: [1, 2, 3, 4] shardTotal: [4] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: lts/* cache: npm - run: npm ci - run: npx jest --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

A matrix generates at most 256 jobs per run, and max-parallel caps how many run at once if you want to leave room for other work.

Sharding shortens the wait for a result. It does not necessarily lower the bill, and it can raise it. Every shard repeats the checkout and the dependency install, and on GitHub’s runners each job is rounded up to a whole minute. If the install takes ninety seconds, four shards pay for it four times, and a shard that finishes in forty seconds is billed for sixty. Choose the shard count by adding up the billed minutes across all the legs, not by the wall-clock time of the slowest one, and stop adding shards when the sum starts to climb.

If your shards write JUnit XML, our runners show it on each job’s page, and with pull request comments switched on each leg of the matrix keeps its own comment rather than sharing one. See test results.

Parallel steps, GitHub’s newest option

On 25 June 2026 GitHub announced that steps in a job can run concurrently. Before this, the workaround was to background a command with & in a shell, which in GitHub’s words often interleaved the logs of several steps. The new keywords keep each step’s logs and execution separate.

parallel takes a group of steps, runs them at once and waits for all of them before the next step. GitHub describes it as shorthand for marking each step background: true and adding a wait after the group. If one step in the group fails, the job fails when that wait is reached. Outputs and environment changes from the steps are only available after it. At most ten background steps run concurrently in a job, and the keyword cannot be used inside a composite action. For a service that must stay up while later steps run, such as a database for the tests, the documentation points to background with wait and cancel instead.

name: checks on: pull_request: permissions: contents: read jobs: checks: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: lts/* cache: npm - run: npm ci - parallel: - name: Lint run: npm run lint - name: Typecheck run: npm run typecheck - name: Unit tests run: npm test - name: Build run: npm run build

Be realistic about what it buys. The steps still share one machine and one checkout. Lint, type checking and unit tests that each use one core gain on a machine with several idle ones. Three CPU-bound builds on a two-core machine will mostly take turns, and a matrix, with a machine per leg, is the better tool. Steps that write to the same files will race, so keep the group to steps that read the checkout and write elsewhere.

Two practical warnings. Linters have not caught up: actionlint 1.7.12, the current release when this was written, rejects both parallel: and the queue key shown earlier, so expect red squiggles in an editor that uses it. And the feature needs a runner new enough to understand it: the Actions runner first shipped the background step engine in v2.335.0, on 8 June 2026. This is GitHub’s feature, so whichever runner you use, run one small job with a parallel: group and read the log before you rely on it.

Where to start

Take them in this order, because each step is cheaper and safer than the next. Stop the double runs and add a timeout to every job; neither can change a result. Add concurrency cancellation on pull requests. Add the path filter and the gate together, never the filter alone, and test the gate on a throwaway pull request with the check marked required before you trust it. Split the fast checks from the slow ones. Shard last, with the billed minutes in front of you, and reach for parallel steps where you can see idle cores.

On our runners a job that is cancelled while it is running is billed for the time it ran, and a job waiting for a machine is not billed, which is why the cancellation rules above are the ones that stop spend. How billing works is on the billing page.

If a job waits instead of running, that is a different problem with its own list of causes: why jobs wait for a runner. To bound what runs can cost, see spending alerts and the spending cap, and the rates are on the pricing page.

Run your CI and your infrastructure on one bill

Connect GitHub

Card required. You pay only for the minutes your jobs run.