How we cut deploy times in half

Caching, parallelism, and a hard look at our CI graph took our median deploy from 18 minutes to under 9.

Abstract concentric rings over a dark purple gradient

Deploys are the heartbeat of a product team. When they’re slow, everything slows down with them: engineers batch changes, incidents take longer to resolve, and the feedback loop that makes a team fast quietly erodes.

Six months ago our median deploy took 18 minutes. Today it’s under 9. Here’s exactly what we changed — and, just as important, what we chose not to.

Start by measuring, not guessing

The first rule of performance work is that intuition lies. We instrumented every stage of the pipeline and pushed the timings to a dashboard before touching a single line of config.

If you can’t see where the time goes, you’ll optimize the stage that feels slow instead of the one that is slow. They’re rarely the same.

The breakdown was humbling. We assumed the test suite was the bottleneck. It wasn’t — it was dependency installation running cold on every single job.

Cache the boring things first

The highest-leverage change was also the least glamorous: a warm dependency cache keyed on the lockfile hash.

.github/workflows/deploy.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # keyed on package-lock.json — this line alone saved ~4 min
- run: npm ci

That one cache: npm line cut a consistent four minutes off every run. Before reaching for exotic solutions, make sure you’re not repeating unavoidable work.

Parallelize the dependency graph

Our pipeline was a straight line: lint → test → build → deploy. But lint and test don’t depend on each other, so we split them into independent jobs that run concurrently.

scripts/pipeline.ts
// Before: sequential — total = sum of every stage
await lint();
await test();
await build();
// After: independent stages run together — total = max(lint, test) + build
await Promise.all([lint(), test()]);
await build();

The key insight is that a pipeline is a dependency graph, not a checklist. Draw the real graph and the safe parallelism becomes obvious.

Only build what changed

For our monorepo, we skip any package whose inputs are unchanged since the last successful deploy on main:

Affected-only builds
# Ask the build tool what actually changed, then build only that.
npx turbo run build --filter="...[origin/main]"

On a typical PR that touches one service, we now build one service instead of eleven.

The results

Metric Before After
Median deploy 18m 8m 40s
p95 deploy 31m 14m
Cache hit rate 0% 94%
Cost per deploy $0.42 $0.19

None of this required a rewrite or a new platform. It required measuring honestly, caching the obvious, and respecting the shape of the dependency graph.

What we deliberately skipped

We considered self-hosted runners and a distributed remote cache. Both would help — but they add operational surface area, and we hadn’t earned the complexity yet. The lesson that keeps paying off: spend the simplest change that moves the metric, then measure again.

More posts