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.
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 ciThat 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.
// Before: sequential — total = sum of every stageawait lint();await test();await build();
// After: independent stages run together — total = max(lint, test) + buildawait 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:
# 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.



