CI Pipelines That Stay Under Ten Minutes
A slow pipeline does not just waste minutes, it changes how a team works: people stop waiting, start batching, and review code that has not finished checking. Here is where the time actually goes, and the four changes that recover most of it.
Quick answer
Ten minutes is the threshold where people stop waiting for a pipeline and go and do something else, and a team that has stopped waiting has effectively stopped running CI. Most slow pipelines are slow for four reasons: dependency installation that is not cached properly, everything running in one sequential job, superseded runs still burning runners because nothing cancels them, and an expensive runner chosen by habit. Fix caching and concurrency first — they are configuration changes rather than restructuring, and between them they usually recover more than half the time. Then split the pipeline by who is waiting for the answer, so the fast checks report in two minutes and the slow ones stop blocking review.
A pipeline that takes twenty-five minutes does not cost you twenty-five minutes. It costs you the habit of waiting for it. People push, switch to something else, and come back an hour later to a failure they now have to reconstruct from memory. Reviews happen on branches that have not finished checking. Someone eventually adds a rule that merging requires a green tick, and the rule makes everything slower without making anything safer.
Ten minutes is roughly the threshold. Below it a person will stay with the change; above it they will not. Getting there is usually four changes, and two of them are configuration rather than restructuring.
Where the time actually goes
Before changing anything, look at one recent run and write down the duration of each step. Almost every slow pipeline we have seen falls into the same distribution, and it is rarely the tests.
| Step | Typical share | Usually fixable by |
|---|---|---|
| Installing dependencies | 30–50% | A cache key built from the lockfile |
| Waiting for a runner | 5–15% | Fewer, larger jobs rather than many tiny ones |
| Building | 15–30% | Build caching, and not building twice |
| Tests | 20–40% | Parallelism, then actually fixing the slow tests |
| Uploading artefacts | 2–10% | Uploading less, and setting a retention period |
The uncomfortable finding for most teams is that half the pipeline is spent getting ready to do the work rather than doing it.
Cache the slow thing, not the big thing
Caching is the single largest win and the one most often configured incorrectly. The behaviour is worth knowing precisely, because the failure mode is silent.
A cache is looked up by exact key first. If that misses, the restore-keys prefixes are tried in order, and where several partial matches exist the most recently created one is returned. Critically, an existing cache cannot be modified — if the contents change, you must write a new cache under a new key.
That last rule is where teams go wrong. If your key is a fixed string, the first run stores the dependencies and every run afterwards restores that same stale copy, then installs the differences anyway. You have added a download to an install you were already doing. Build the key from a hash of the lockfile so it changes exactly when the dependencies change:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
Two limits are worth holding in mind. A repository gets 10 GB of cache by default, and when that fills, entries are deleted by last-access date, oldest first. Anything not read for seven days is removed regardless. So a cache for a branch that builds twice a month will never be there when you want it, and caching enormous build outputs can evict the dependency cache that was doing the real work.
Cache what is slow to produce, not what is large. A 400 MB dependency tree that takes ninety seconds to install is worth caching. A 4 GB build output that takes twenty seconds to regenerate is actively harmful, because it evicts the thing that was helping.
Stop paying for runs nobody is waiting for
This one takes four lines and is skipped almost universally. When someone pushes three times in ten minutes, the first two runs keep going to completion. Nobody will ever read those results. They occupy runners, delay the run that matters, and are billed in full.
Concurrency groups fix it. Jobs sharing a group run one at a time, and with cancellation enabled a new run terminates the one already in progress rather than queueing behind it:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Keying the group on the branch reference means each branch cancels only its own superseded runs. On a busy repository this alone can noticeably shorten the queue, because a meaningful share of the fleet was working on results nobody would read.
The one place to be careful is deployment. A deploy cancelled halfway through is worse than a deploy that queues, so give deployment jobs their own group with cancellation switched off. The route you deploy through determines how much this matters — a platform doing atomic swaps is far more forgiving here than a pipeline that copies files onto a live server.
Split by who is waiting
Most pipelines are one long sequential job because that is how they started. The fix is not simply to parallelise everything, it is to ask who is waiting for each answer.
- For the person who just pushed: lint, type-check, unit tests. These block a human right now, so they run on every push and belong in one job that finishes in two or three minutes. One job, not three — each additional job pays the runner startup cost again, and for short checks that overhead can exceed the work.
- For the reviewer: integration tests and a production build. These run on the pull request. Five to eight minutes is acceptable because review takes longer than that anyway.
- For the team: browser tests across several targets, security scanning, dependency audits. These belong on a merge queue or a schedule. Nobody is sitting watching them, and running them on every push to every branch is where most wasted minutes live.
Splitting this way is what actually gets you under ten minutes, because you stop measuring the wrong thing. The number that matters is how long until the person who pushed learns something useful, not how long until every check has finished.
The runner is a bill as well as a clock
Per-minute rates differ by more than people assume. On GitHub-hosted runners a single-core Linux machine is the cheapest tier, a 2-core Linux machine is a small multiple of it, Windows costs noticeably more, and macOS is roughly ten times the 2-core Linux rate. Public repositories and self-hosted runners are not billed at all.
The practical consequence: a job running on macOS because the workflow was copied from somewhere is paying an order of magnitude more for work that has nothing to do with Apple platforms. Only the steps that genuinely need macOS — building or signing for Apple targets — should be there. Everything else goes on Linux.
Two further details catch teams out. Larger runners are charged even while you still have included minutes remaining, so switching to a bigger machine to save time is a real cost decision rather than a free one. And storage is billed separately from compute: artefacts and caches accrue a monthly charge per gigabyte, with artefacts costing several times what cache storage does. A workflow uploading a full build output on every run, with the default retention, quietly becomes a storage line item. Set a short retention period and upload only what someone will actually download.
When the tests themselves are the problem
All of the above is configuration. At some point you run out of configuration, and what is left is a test suite that is genuinely slow. Two things help more than parallelism.
The first is finding the handful of tests responsible for most of the time. Nearly every suite has a long tail where a small number of tests — usually ones that hit a real database, sleep for a fixed interval, or spin up a browser to check something that could be checked without one — account for a large share of the runtime. Most test runners can report the slowest tests. Reading that list is ten minutes well spent.
The second is being honest about which tests earn their runtime. A browser test that repeats what three unit tests already cover is costing you minutes on every push for no additional information. Deleting it is a legitimate performance fix, and easier to justify once you can point at what it costs per week.
It is also worth checking what your pipeline is not telling you. A suite that is green while production is throwing errors is a suite testing the wrong things, and no amount of speeding it up helps — error tracking in production is what closes that gap, not more CI.
What we would do first
In this order, because it is roughly the order of return on effort:
- Add concurrency with cancellation to every workflow except deployment. Four lines, immediate effect on both queue time and cost.
- Fix the cache key so it is a hash of the lockfile, with a prefix restore-key as a fallback. Then confirm on the next run that it reports a hit.
- Move anything not on Linux onto Linux unless it genuinely needs another platform.
- Split the workflow by audience — fast checks on push, slower ones on the pull request, the long tail on a schedule.
- Only then look at parallelism, and measure it, because splitting a four-minute job into four one-minute jobs often produces four jobs that each spend forty seconds starting up.
The first three are an afternoon and typically recover half the time. The fourth is where the remainder is. If you are still above ten minutes after all of that, the pipeline is no longer the problem and your test suite is, which is a more useful thing to know than it sounds.
Pros and cons
Pros
- Caching and concurrency are configuration changes, not rewrites, and land in an afternoon
- Splitting by audience makes review usable long before the full suite finishes
- Cancelling superseded runs cuts both wall-clock time and the bill at once
- Runner choice is often pure habit, and the cheapest option is frequently the fastest
Cons
- Aggressive parallelism can cost more than it saves once fixed startup time dominates
- Cache keys are easy to get wrong, and a stale cache is worse than none at all
- Splitting pipelines means more places for a check to be quietly skipped
- The slowest step is often the test suite itself, and no amount of configuration fixes that
Alternatives worth considering
Free for public repositories and self-hosted runners; billed per minute with a multiplier by platform.
Tightly integrated with the repository, with its own runner fleet or your own machines.
Orchestration hosted for you, execution on your own hardware. Predictable cost at high volume.
Self-hosted runners
Free of per-minute charges and much faster on cache-heavy work, at the cost of maintaining machines.
Frequently asked questions
Why ten minutes specifically?
It is roughly the point at which waiting stops being reasonable. Under about ten minutes a developer will stay with the pull request, watch it go green and merge. Past that they switch to something else, and the context they were holding is gone. The cost of a twenty-five minute pipeline is not fifteen extra minutes of compute, it is the reload of everything the person had in their head, several times a day, across the team.
Is caching dependencies always worth it?
Almost always, but only if the key is right. On GitHub Actions a cache is looked up first by exact key, then by the restore-keys prefixes in order, and an existing cache can never be modified — you can only write a new one under a new key. So build the key from a hash of your lockfile. If the key does not change when dependencies change you will keep restoring a stale cache and reinstalling anyway, which is slower than not caching at all. Caches that go unread for seven days are removed, and the repository limit is 10 GB with the oldest-accessed entries evicted first.
Should we run everything on every push?
No, and this is the change with the best ratio of effort to payoff. Split by who is waiting for the answer. Lint, type-check and unit tests are for the person who just pushed, so they run on every push and need to be fast. Full browser tests across several targets, security scans and long integration suites are for the team, so they can run on the pull request, on a merge queue, or on a schedule. The mistake is treating every check as equally urgent when only some of them block a human.
Are bigger runners worth the money?
Sometimes, and it is easy to check rather than guess. Per-minute rates differ sharply by platform: on GitHub-hosted runners a 2-core Linux machine is a fraction of the cost of a macOS one, so a job that runs on macOS out of habit rather than necessity is paying roughly ten times the rate for the same work. Larger runners are also charged even when you still have included minutes left. Move what genuinely needs a specific platform onto it, and put everything else on Linux.
Sources
Everything factual in this article traces back to one of these. Vendors change pricing and limits without changing the URL, so each entry records the date we last read it.
- GitHub Actions billing
GitHubchecked September 4, 2026
- Dependency caching reference
GitHubchecked September 4, 2026
- Control the concurrency of workflows and jobs
GitHubchecked September 4, 2026
Written by
ToolNest Editorial
Editorial team
ToolNest's editorial byline. Our articles summarise and compare software using vendor documentation, changelogs, pricing pages and published reporting, and are drafted with AI assistance under human review. Where we have not used a tool ourselves, we say so rather than implying otherwise.