How to Keep a CI Pipeline Under 10 Minutes
A continuous integration pipeline sets the pace of everything downstream. When it takes 40 minutes, people batch their changes, switch to other work while they wait, and lose the thread of what they were doing. Ten minutes is a long-standing target: Martin Fowler's article on continuous integration calls the Extreme Programming guideline of a ten-minute build "perfectly within reason" for most projects. This guide covers how to measure a pipeline and bring it under that line, using GitHub Actions for the examples.
Measure the pipeline before tuning it
The number developers feel is wall-clock time from push to result. That time has parts, and each part has a different fix:
- Queue time: how long a job waits for a runner before it starts.
- Setup time: checkout, toolchain setup and dependency installation.
- Critical path: the longest chain of jobs that must run one after another.
- The slowest job on that path, which is usually a test suite.
GitHub's Actions metrics give organizations performance data including average run times, average queue times and failure rates, broken down by workflow, by job, by repository, by runtime OS and by runner type. Within a single run, the run summary shows each job's duration. Track the trend over a few weeks, not one run, because a pipeline rarely gets slow all at once. It gets slow a few seconds per commit.
Run independent jobs in parallel
In GitHub Actions, the workflow syntax reference states that jobs run in parallel by default. They only run in sequence when you chain them with needs, and when a job fails or is skipped, every job that needs it is skipped too, unless a condition says otherwise.
That makes needs the first thing to audit. Every link you add puts one job's full duration onto the critical path. Keep a dependency only when a job truly consumes another job's output, such as a deploy that needs a build artifact. Lint, type checking, unit tests and integration tests usually do not depend on each other, so they can all start at once.
For the same job across several variations, such as operating systems or language versions, use a matrix. A matrix can generate up to 256 jobs per workflow run.
Cache dependencies
Installing dependencies from scratch on every run is often the largest piece of setup time. GitHub's dependency caching reference covers how it works:
- The cache action looks for an exact match on your
key, then for partial matches, then through yourrestore-keysin order. Itscache-hitoutput tells you whether the key matched exactly. - Building the key from a lockfile hash, such as
npm-${{ hashFiles('package-lock.json') }}, means the cache changes exactly when dependencies change. - The setup actions for common package managers, including
setup-node,setup-python,setup-java,setup-ruby,setup-goandsetup-dotnet, can create and restore dependency caches for you with minimal configuration. - As of September 2026, each repository gets 10 GB of cache storage by default (administrators can raise the limit, and usage beyond 10 GB is billed), and entries not accessed in over 7 days are removed.
Two scoping rules decide whether you actually get hits. First, a run can restore caches from its own branch or the default branch, and a pull request run can also use its base branch. Second, a cache saved by a pull request run is scoped to that pull request's merge ref, so only re-runs of the same pull request can restore it. The practical consequence: if nothing on the default branch ever saves the cache, every new pull request starts cold. Run the workflow on pushes to main so a fresh cache is always there for new branches.
Keep secrets out of cached paths. GitHub's docs say not to store access tokens or credentials there, and they explain that runs triggered by lower-trust events that resolve to the default branch, such as pull_request_target, issue_comment and workflow_run, get read-only access to the default branch's caches by default, to prevent cache poisoning. A workflow can opt back into writing, but that reopens the risk.
Split the test suite across machines
Once setup is lean, the test suite is usually what remains. Sharding runs a slice of the suite on each of several machines at the same time. Playwright's sharding guide shows the pattern: npx playwright test --shard=1/4 on the first machine, --shard=2/4 on the second, and so on.
How the work is divided matters. By default, Playwright assigns whole test files to shards, so a few very large files can leave one shard running long after the others finish. With fullyParallel: true, it distributes individual tests, which balances the shards much better. Each shard can write a blob report, and npx playwright merge-reports combines them into one report at the end.
Here is a workflow that uses a matrix to run four shards, with the setup action handling the npm cache:
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}/4
More shards are not free. Every shard repeats checkout, setup and installation, so past a certain point the overhead outweighs the savings. Add shards until the slowest one stops getting faster, then stop.
Order the work so failures show up early
A fast pipeline is also one that tells you about problems early.
- Put cheap checks first inside a job. Steps run in order, so run formatting, linting and type checks before a long test step. A typo then fails in the first minute instead of the tenth.
- Understand
fail-fast. In a matrix,fail-fastdefaults to true: if any matrix job fails, GitHub cancels the in-progress and queued jobs in that matrix. That saves runner time. Set it tofalsewhen you need the full picture, for example to see which operating systems fail. - Cancel outdated runs. A
concurrencygroup withcancel-in-progressstops the old run when you push a new commit to the same pull request. The example above only does this for pull requests, so a run onmainthat has already started is never cut off halfway. - Set a timeout. A job's
timeout-minutesdefaults to 360, so a hung job can hold a runner for six hours before GitHub cancels it. Set it a little above the job's normal duration.
Skip workflows a change cannot affect
Some changes do not need the whole pipeline. A workflow's push and pull_request triggers accept paths and paths-ignore filters, so a documentation-only change can skip a workflow entirely. You cannot use both filters for the same event; paths with ! patterns covers the mixed case.
There is a trap here. The workflow syntax reference warns that when a workflow is skipped because of path filtering, its checks stay in a "Pending" state, and a pull request that requires those checks is blocked from merging. Only put path filters on workflows whose checks are not required, or keep the required workflow running and make its expensive jobs conditional inside it.
Keep one safety net in place: run the full pipeline on every push to main, so anything a filter wrongly skipped is caught within one commit.
Quarantine flaky tests
A flaky test passes and fails on the same code. Even a small rate adds up fast. Google's testing blog reported in 2016 that about 1.5% of all its test runs produced a flaky result, that almost 16% of its tests had some level of flakiness, and that about 84% of the transitions from passing to failing it observed involved a flaky test. The cost is not only reruns. People learn to ignore red builds, and real failures slip through.
Automatic retries hide the problem rather than fix it. The same post describes a tool that quarantines a test when its flakiness gets too high: quarantining removes the test from the critical path and files a bug for developers to fix it. It also names the risk, which is that a quarantined test could mask a real race condition.
A workable quarantine policy:
- Keep a quarantine list in the repository, with an owner and a date for each entry.
- Run quarantined tests in a separate job that reports results but does not block merging, so you keep collecting data.
- Fix or delete each quarantined test within an agreed time, and review the list weekly.
The single most useful habit is to write the 10-minute budget down and check it every week against the median and slowest runs. Treat a pipeline that crosses the line the way you would treat a failing test: someone owns it until it is back under.