There is a well-worn rule of continuous integration: if the pipeline takes longer than ten minutes, it stops being a feedback loop and starts being an interruption. When pull-request checks run past fifteen minutes, developers context-switch to other work, break their flow, and lose the fast feedback that made CI worth having in the first place (Marcus Felling, 2025).
The good news is that most GitHub Actions pipelines are slow for a small number of fixable reasons, and getting under ten minutes is usually a config problem, not a rewrite. In one real case, caching, parallelisation, and a few faster tests took a workflow from ten minutes down to three minutes and forty-nine seconds (Raphael Arce, 2025).
This post is the practical version: five moves that reliably pull a pipeline under the ten-minute line, with the config for each. Cache dependencies, shard the tests, run only what changed, cancel superseded runs, and cache Docker layers.
Cache dependencies: the first and biggest single win
Reinstalling dependencies from scratch on every run is the most common time sink, and the easiest to fix. Enabling the built-in cache in the official setup actions can cut an install from around three minutes to forty seconds, roughly a fivefold speedup, with a cache hit rate typically between 70 and 90 per cent (Easton, 2026).
# One line of cache config, keyed on the lock file
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # caches ~/.npm, keyed on package-lock.json
- run: npm ci --prefer-offline --no-audit
The key is tied to a hash of the lock file, so the cache stays valid until dependencies actually change, and the official setup actions handle that keying for you in a single line (Easton, 2026).
The trade-off is small but worth knowing: the cache has a size limit and is evicted after a week of inactivity, so it speeds up active repositories rather than ones that build rarely, and a badly designed key that never hits is just overhead.
Shard the test suite across parallel runners
For most teams, the test suite is the single largest block in the pipeline, and running it serially is the bottleneck. Sharding splits the suite across several runners that execute at the same time, and the impact is dramatic: a suite that takes forty minutes serially finishes in about ten across four runners, the same tests running 75 per cent faster (Rakshitha, 2025).
# Split the suite into 4 shards that run concurrently
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # one shard failing must not cancel the rest
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
Setting fail-fast: false matters, because otherwise one shard failing cancels the others and you lose the full picture of what broke. To keep the shards balanced as the suite grows, split them by previous run times rather than by file count, so no single runner becomes the slow one (Jeevi Academy, 2025).
The trade-off is that each shard repeats the setup, installing dependencies and browsers again, so past a point more shards cost more total minutes for less wall-clock gain. Find the balance where feedback is fast without the runner bill ballooning.
Only run the jobs the change actually touches
The fastest job is the one that does not run. Rather than running the whole pipeline on every commit, trigger jobs only when files they care about actually change, and keep pull-request checks lean by moving exhaustive suites to a nightly schedule (Modexa, 2025).
# Fast checks on every PR; the heavy matrix only runs at night
on:
pull_request:
paths: [ "src/**", "package.json", "package-lock.json" ]
schedule:
- cron: "15 2 * * *" # full e2e + cross-platform nightly
jobs:
pr-fast: # lint, unit tests, smoke build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run verify
nightly-full:
if: ${{ github.event_name == 'schedule' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run test:e2e
This is the practical form of test impact analysis: run the fast, relevant checks on every change, and reserve the slow, comprehensive suite for a schedule where nobody is waiting on it (Jeevi Academy, 2025).
The trade-off is coverage timing. A bug that only the nightly suite catches is found hours later, so the fast path has to be trustworthy enough that what it skips is genuinely unlikely to break, or you have just moved the pain to the morning.
Cancel superseded runs so nothing waits on obsolete code
On an active branch, developers push several times in quick succession, and by default every push starts a fresh run while the old ones keep going. A ten-minute pipeline pushed three times in half an hour queues thirty minutes of compute, and most of it is testing code that has already been replaced (Gitdash, 2026).
# A new push cancels the previous run on the same PR or branch
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
The group name is what makes this safe. Using the pull request number, falling back to the branch reference, scopes cancellation to one PR at a time, so a new commit only cancels its own stale run. A static group name like ci is the classic mistake, because then one developer's push cancels everyone else's build (Gitdash, 2026).
The one caution is to never apply this to deployment jobs. Cancelling a release or a migration mid-flight can leave a partial deploy or a broken schema, so keep cancellation for CI and let deployments run to completion.
Cache Docker layers, and put a timeout on every job
If the pipeline builds a container image, a cold build on every run is a large, avoidable cost. Buildx with the GitHub Actions cache backend reuses unchanged layers between runs, so only what actually changed is rebuilt (Easton, 2026).
# Reuse Docker layers across runs, and never let a job hang forever
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15 # fail fast instead of hanging to the 6h ceiling
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/org/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Ordering the Dockerfile so the least-volatile layers come first, base image and OS packages before your code, maximises how much of that cache is reused on each build (Jeevi Academy, 2025). The timeout-minutes matters just as much for speed, because without it a hung job runs until the six-hour ceiling instead of failing fast (FixDevs, 2026).
The trade-off is that layer caching rewards disciplined Dockerfiles and punishes sloppy ones. If your image invalidates its cache near the top on every commit, the backend cannot help you, so the caching and the layer order have to be designed together.
The part worth sitting with
None of these five moves is clever, and that is the point. A slow pipeline is almost never one hard problem; it is dependencies rebuilt from scratch, a test suite run in one long line, the whole workflow triggered on changes it does not touch, obsolete commits still churning through runners, and cold Docker builds, each adding a few minutes until the total quietly crosses the line where people stop trusting it. Fix them and the pipeline drops back under ten minutes, which is the threshold that decides whether developers wait for CI or work around it. The techniques are an afternoon of YAML; the payoff is a feedback loop tight enough that a red build is caught while the change is still fresh in someone's head, instead of an hour later when they have already moved on. Cache what repeats, parallelise what is serial, skip what did not change, cancel what is stale, and the ten-minute pipeline stops being an aspiration and becomes the default.
Author note
I am Manjunaathaa, an Associate DevOps Engineer at Frigga Cloud Labs. I work across AWS, GCP, and Azure daily, with GitHub Actions as my deployment backbone. My focus is Proactive Resilience, and a fast pipeline is part of it, because a ten-minute pipeline is one people trust and a forty-minute one is one people route around. Every practice in this post is something I actually run in production, not something I read about. The lever that surprised me most was sharding: I cut a suite from over half an hour to under eight minutes without deleting a single test, just by splitting it across runners and caching per shard. The one I underrate at my peril is cancelling superseded runs, because most of a busy pipeline's minutes go on commits that have already been replaced. I treat CI speed as a number I watch, not a thing I hope stays fast. If you want to compare a slow ci.yml against a fast one, let's connect on LinkedIn → Manjunaathaa.
DevOps, Engineering, GitHub Actions, CI/CD, Pipeline Optimization, Test Sharding, Caching, Parallelization, Docker, Runners
