Scalable / Engineering blog / From 4m 12s to 47s
● Featured 14 May 2026 · Build platform · 14 min read · v4.18

How we cut median build time from 4m 12s to 47s — without buying a single faster machine.

Six months ago, our build pipeline was a parade of caches that didn't cache, layer pulls that didn't pull, and one extremely brave Python script. Here's everything we changed, in order, what didn't work, and what we'd do differently next time.

PR
Pavel Ritter Staff Engineer · Build Platform · joined Mar 2023

On the morning of November 7, 2025, our median build time crossed five minutes. Not the 99th percentile — the median. The graphs were leaning the wrong way for three quarters in a row, and we'd run out of "it's because we're growing" as an excuse.

This is the story of how we got it down to 47 seconds — across a fleet of 14,000 builds a day, on the same hardware we had in 2024. Six pull requests, three dead ends, and one Python script that we were genuinely embarrassed by.

i
Numbers in this postAnything I quote is from our internal build_duration_seconds histogram, sampled over a 7-day rolling window. The "before" snapshot is the week of 03 Nov 2025; the "after" is the week of 04 May 2026.

01The numbers, before any of this

Here's where we started. Same chart, two lines, six months apart. The "before" line was reactive — we'd built the pipeline three years ago and most of it had been left alone since.

Build duration · p50 / p95 / p99 · weeklynov 2025 → may 2026
8m 5m 2m 0 nov dec jan feb mar apr may PR #1: cache dedup PR #3: parallel push PR #6: cleanup
p95 p50 shipped changes

Median build time over 6 months. Three distinct drops, each tied to a single PR. The first shipped Dec 8 (cache dedup), then plateau through January, then a second drop in late Feb (parallel push), and a smaller cleanup in April. No new hardware.

If you skim only one chart, that's it. Three step changes, three pull requests, three engineers — Pavel (me), Lia, and a very patient intern named Aki. Below is the actual order it happened and what we learned, including the parts that didn't work.

02Why builds were actually slow

I spent two weeks just looking. No code changes. The temptation when something is slow is to immediately start writing patches, but for a system that ran 14,000 builds a day across 18 regions, I needed to know which build I was looking at.

I sampled 80 random builds across the week — 40 from EU regions, 40 from US — and walked through them span-by-span in our trace UI. The results were not what I expected.

The three things eating most of the time

None of these is a clever optimization. They were just the kind of thing that creeps in when nobody owns a system end-to-end for a year and a half.

"None of this was a clever optimization. It was three engineers fixing things that should never have shipped that way."

03PR #1 — Cache layer dedup

The first change was the cache-key fix. We were hashing build.config.json, which was generated from environment variables at build time and changed on every CI run. We needed to be hashing the lockfile.

The fix was small. The diff was small. The effect was not small:

go builder/cache/key.go ⎘ Copy
1 func cacheKey(step *BuildStep) string {
2   h := sha256.New()
3   h.Write(step.Command)
4 - h.Write(readFile("build.config.json")) // changes per run
5 + h.Write(readFile(step.LockfilePath))    // stable per release
6   return hex.EncodeToString(h.Sum(nil))
7 }

Cache hit rate moved from 39% to 82% in 36 hours. Median build time dropped from 4m 12s to 2m 18s — about half of the eventual win, in a single change.

!
What I missedThe bug was in code I'd reviewed and approved, in 2024. Reviewing code is not the same as understanding it. I now write a one-line "why" comment next to every cache key.

04PR #3 — Parallel push, region-aware

The next big win was pushing the final image. We had a builder that, after producing an image, would push it region-by-region: eu-central first, then eu-west, then us-east, and so on. Each push was 5–8 seconds, and there were 18 of them.

The "obvious" fix was to push to all 18 in parallel. But our registry has per-region rate limits, and we'd hit them hard if we did that naively. The actual fix had two parts:

StageBeforeAfterΔ
Push to first region5.8s5.4s−7%
Push to all 18 regions94s9.2s−90%
Layer pull on cache hit22.1s3.4s−85%
Total p50 build2m 18s62s−55%

The pull-side speed-up came along for free from the same code path — we already had the parallel-fetch primitive working in the push direction.

05The Python script we were embarrassed by

This is the part that took longest to ship, partly because we kept rewriting it, and partly because nobody wanted to look at the existing version.

Somewhere in 2023, a previous engineer wrote a Python script called finalise.py that ran after every build. Its job was to update a metadata record in our control database. It ran serially, took 7–14 seconds, and was responsible for about 4% of total build time.

It was 380 lines of Python making 34 separate HTTP requests, in a loop, with no connection pooling. It also re-read a 4 MB JSON manifest from disk on each iteration.

We rewrote it in Go, in 90 lines, using one connection and one in-memory copy of the manifest. The new version takes 0.6 seconds on the slow path. That's not even the interesting story — the interesting story is that this had been on three different engineers' "I'll look at it next sprint" lists for over a year. It was the perfect size of problem to keep getting deferred.

"The script wasn't bad. It was on three engineers' lists for a year. That's a different problem."

06Three things that didn't work

I want to be honest about this: we tried things that didn't work. Some of them looked great in a small test and got worse in production.

Dead end 1 · Pre-warming the cache from the previous build

The idea: when a build starts, immediately start pulling layers from the previous build of the same service. If they're shared (they usually are), they'll be hot when we need them. In benchmarks: 8% improvement. In production: 1% worse, because the pre-warm contended with the actual pulls and saturated our edge bandwidth.

Dead end 2 · "Smart" build ordering

The idea: schedule fast services first so the queue cleared faster. In benchmarks: looked promising. In production: caused two outages of our build scheduler within a week, both because the "smart" code had a corner case where it decided every service was fast and ran 400 in parallel.

Dead end 3 · Bazel

I'm not going to litigate Bazel here. We tried it for two months. The team that tried it didn't ship anything else for two months. We rolled back to docker buildx with our own cache layer, and shipped four PRs in three weeks.

i
The lesson, if there is oneThe biggest win came from a one-line bug fix. The second biggest came from doing the obvious thing well. The "clever" ideas mostly hurt. Try simple before clever, and always benchmark in production, not in a lab.

07What we'd do next

Where to next? A few things on my list, in rough priority order:

That's mostly it. Six pull requests, no new hardware, three engineers, and one Python script we still occasionally apologize for. Email pavel@scalable.systems if you want to dig into any of it.

— pavel.
Pavel Ritter · staff engineer · build platform · published 14 May 2026
build perf caching retro

Get the next post
in your inbox.

One email, every Monday. Engineering posts, post-mortems, and the occasional design doc we wished we'd published a year ago.