A forty-minute pipeline doesn't just slow releases. It changes behavior: engineers batch changes to avoid the wait, batched changes make failures harder to bisect, and soon "the build is red" is a permanent condition someone is assigned to. Getting under ten minutes is often achievable in a week or two of focused work, though it depends on the suite you've inherited; it's the first lever we pull in delivery engagements. Here's the order we do it in.
1. Measure before touching anything
Every CI system can emit per-step timings. Pull a month of them and find where the minutes go. The distribution is always lopsided. Dependency installation and the test suite usually dominate, with the container build close behind; the linter everyone blames rarely makes the top three. You cannot argue with a histogram, and neither can the team member who insists otherwise.
2. Cache dependencies properly
The single biggest win is almost always dependency caching keyed on the lockfile:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
The subtle part is cache invalidation discipline: key on the lockfile, not the
branch. Branch-keyed caches miss constantly and quietly rebuild the world.
Minutes of npm ci collapse into seconds.
3. Shard the test suite, and fix the flakes you find
Split tests across parallel runners by measured timing, not file count, so shards finish together. The arithmetic is friendly: split across eight runners, a fifteen-minute suite lands somewhere near two minutes, plus a little scheduling overhead.
Sharding has a side effect nobody warns you about: flaky tests that hid inside a long serial run start failing loudly in parallel. Budget time for this. A retry-once policy is an acceptable painkiller for a week; as a permanent setting it's how suites rot.
4. Layer container builds for the cache
Most Dockerfiles invalidate their own cache by copying source before installing dependencies. Order matters:
COPY package.json package-lock.json ./
RUN npm ci # cached until the lockfile changes
COPY . . # source changes don't bust the install layer
RUN npm run build
With BuildKit layer caching pushed to a registry, the common-case rebuild costs a fraction of the cold build, because the expensive layers never re-run.
5. Right-size runners last
Bigger runners are the lever people reach for first because it's a one-line change. Do it last: after caching and sharding, you know your real CPU and memory profile, and you'll often downsize, paying less for a faster pipeline than the original.
The stopwatch isn't really the point. When the pipeline is fast, people merge smaller changes, and merge size is the variable that decides how scary a deploy is.