Your CI is the slowest developer on the team
A twenty-minute pipeline doesn't cost twenty minutes. It costs the context switch, the batching, and the review you skipped.
There is a number in your organization that nobody owns and everybody pays: the wall-clock time between pushing a commit and knowing whether it is good.
Call it T. If T is two minutes, your team behaves one way. If T is thirty minutes, it behaves completely differently, and the difference is not "things take twenty-eight minutes longer."
what actually happens as T grows#
Below ~2 minutes, you wait. You keep the change in your head, you see the result, you fix it. The loop is tight enough that debugging is interactive.
Between 2 and 10 minutes, you context switch. You go read something else. When the result comes back you have to page the change back in. The reload cost is real and it is roughly proportional to how complex the change was — which means it is worst exactly when you can least afford it.
Past 10 minutes, behavior changes qualitatively. People start batching. They stop pushing small commits because the feedback is too expensive per commit, so they push bigger ones, which are harder to review and more likely to fail, which makes each failure more expensive to diagnose. It is a doom loop and it is entirely emergent from one number.
Past 30 minutes, people route around CI. They test locally in ways that diverge from CI, they merge on green-ish, they add [skip ci], and eventually somebody proposes a nightly build as the "real" signal, at which point you have given up.
the diagnostic#
Before optimizing, measure the right thing. Not average pipeline duration — p90 time-to-signal for the check that actually blocks merge. Most teams measure the wrong thing here and optimize a stage nobody was waiting on.
Then break it down:
queue wait ← runners are saturated or your concurrency limit is wrong
checkout ← you're doing a full clone of a repo with 200k commits
dependency install ← almost always the biggest fixable chunk
build ← is anything cached? really?
test ← is it parallel? is it parallel *well*?the fixes, roughly in order of return#
- Cache dependencies properly. Not "we have a cache step." Verify the hit rate. A cache that misses on every branch because the key includes the branch name is worse than no cache, because it also uploads.
- Shallow clone.
fetch-depth: 1unless you genuinely need history. If you need history for one job, do it in one job. - Split the blocking check from the exhaustive check. Lint, typecheck, and unit tests block merge. Integration, e2e, and the full matrix run after, and page someone if they fail. Not everything needs to gate.
- Parallelize by timing, not by file count. Splitting tests evenly by count gives you one shard that takes four times as long as the others. Split by historical duration.
- Kill the flaky tests. A test that fails 2% of the time in a suite of 50 parallel jobs means your pipeline fails constantly. Quarantine them the day they are identified. A quarantined test is a bug ticket; a flaky test in the blocking path is a tax on everyone forever.
- Bigger runners. This is the least intellectually satisfying fix and often the best one. Engineer time costs more than compute. Do the arithmetic before you spend a sprint on a clever solution.
the cultural part#
Someone has to own T. Not "the platform team should look at CI sometime." A named person, a dashboard, and a number that goes in the same review as uptime.
Because the alternative is that it degrades a little every sprint — one more test, one more dependency, one more required check — and nobody notices until it is thirty minutes and everybody has quietly stopped trusting it.
— Dom, February 4, 2025