Eighteen minutes. That was how long our CI pipeline took to run at the start of this year. Doesn't sound catastrophic, but when you're merging five to ten times a day and the pipeline gates every merge, eighteen minutes of waiting adds up to a genuine drag on how fast the team can ship.

We got it to four minutes. Here's exactly what we did.

Step 1: Profile before you optimise

The temptation when a build is slow is to immediately start guessing at what to fix. We resisted this and spent a day just profiling the pipeline to understand where the time was actually going.

The breakdown was roughly: 6 minutes on dependency installation, 5 minutes on building the Docker image, 4 minutes running the test suite, 3 minutes on linting and type-checking. A few minutes miscellaneous. This told us immediately that dependency installation was the single biggest target. Almost a third of the total time.

Step 2: Cache your dependencies properly

We were on GitHub Actions, and our caching setup was... technically present but not doing much. The cache key was based on the lock file, which is correct, but we were caching the entire node_modules directory, which is large and fragile.

The fix was switching to restoring the npm cache (the tarballs GitHub Actions caches automatically) rather than caching node_modules directly, and adding a fallback cache key for the base OS image so that a lock file change didn't require downloading everything from scratch.

This alone cut about 4 minutes off the dependency installation step on cache hits. Cache miss performance improved too because we were now fetching from GitHub's CDN rather than npm's registry for most packages.

Step 3: Fix the Docker layer order

Our Dockerfile was doing something you see a lot in projects that grow organically: copying the entire project directory before installing dependencies. This meant that any change to any file (a comment in a component, an updated test) invalidated the dependency layer and forced a full reinstall in the Docker build.

The fix is mechanical: copy the package files first, run the install, then copy everything else. Any Docker tutorial from the last five years will tell you this. We'd just never gotten around to doing it properly.

After the fix, the Docker build step went from 5 minutes to under 90 seconds on most commits because only the final COPY instruction was invalidated.

Step 4: Parallelise the test suite

Our tests were running sequentially on a single GitHub Actions runner. With about 1,400 tests, this was the obvious next target once we'd addressed caching and Docker.

We split the test suite into four shards running on parallel jobs. The orchestration is straightforward with GitHub Actions' matrix strategy: you define the number of shards and each job receives an index, then Jest (or your test runner of choice) splits the test files accordingly.

Four parallel jobs running 350 tests each takes about the same wall time as running a single shard with 350 tests. The test step went from 4 minutes to just over 1 minute.

Step 5: Move linting out of the critical path

Linting and type-checking are important, but they don't need to block deployment in most scenarios. We refactored our pipeline so that type-checking runs as a separate parallel job that doesn't gate the deployment flow. If type errors exist, the job fails visibly and engineers are expected to fix them, but it doesn't add to the critical path time.

This is a slightly philosophical decision (you could argue linting should block deploys) but in practice our engineers catch type errors before pushing and the runtime cost of occasionally deploying with a type warning is lower than the productivity cost of the wait.

18 min
before
4 min
after
78%
total reduction
~2 hrs
daily developer time reclaimed

What we'd still like to improve

Four minutes is good. We think there's a path to under two. The remaining low-hanging fruit: test file parallelisation across more shards, incrementally building only the packages that changed in a monorepo setup, and potentially moving some of the heavier integration tests to a separate pipeline that runs on schedule rather than on every push.

The returns diminish, but so does the friction. Every minute off the build cycle is a minute more the team spends in flow rather than context-switching while waiting for green lights.