Blog/Pipelines

Why wall-clock time lies in CI/CD

Ondřej Podhorný

Ondřej Podhorný

2026-09-19 · 7 min read

Codebase Pulse before and after: build 93s to 89s, CPU work 307s to 202s, artifacts 1101 MB to 110 MB

Five seconds on the clock. Ninety percent less disk I/O. A third of the CPU work gone. When you only watch the stopwatch, you miss the build.

I measured a nopCommerce monorepo — 40+ projects, modular plugins — and changed how MSBuild copies references.

On a clean build the clock moved 93s → 88s. Five seconds. Easy to call it a failure and walk away.

Look under the hood:

  • Total CPU work: 307s → 202s (−34%)
  • Artifacts written: 1,101 MB → 109 MB (−90%)
  • Disk I/O phase: 16.8% → 0%
  • TFM handshake target: −86%

That is 105 vCPU-seconds and a gigabyte of disk I/O off one build. The stopwatch barely noticed.

The CopyLocal cascade

nopCommerce plugins each referenced the shared core projects. With Copy Local left on, each of the 30+ plugins copied about 35 MB of those assemblies into its own bin/ folder. Over a gigabyte of duplicate binaries on every build. The web host already had one copy. The plugins did not need thirty more.

MSBuild also spent time negotiating identical target frameworks across those plugin references — the TFM handshake.

The change was in Directory.Build.targets. For plugin projects, shared project references stopped copying into each plugin bin/ (Private=false), and the TFM handshake on those references was skipped. Lock-file assemblies were no longer copied the same way. The host already had the core; the plugins stopped duplicating it.

That is the mechanism, not a recipe. The interesting part is what the clock did next.

Why the clock barely moved

MSBuild is a DAG. Clearing the I/O and TFM bottleneck did not make the graph shorter. It exposed the next serial blocker: nopCommerce’s custom plugin cleanup target (NopTarget).

The wait moved. It did not disappear. If you only read wall-clock, you conclude “5 seconds, not worth it.” If you read CPU work, I/O, and the critical path, you see the copies are gone — and NopTarget was always behind them.

Developers do not live on clean builds

A 5-second clean-build gain is a side effect. The inner loop is where people work.

Before the change, touching a single core file triggered the same CopyLocal cascade — 1,101 MB of duplicate binaries on every rebuild. After: 109 MB. That I/O number is measured. The hours below are not: they only ask what happens if you treat the avoided write as the same cautious 5 seconds the clean build showed on the clock.

For a team of 15, at 15 incremental builds a day:

15 × 15 × 5s ≈ 19 minutes/day ≈ 6.3 hours/month.

Nearly a workday a month, with no change to business logic. Harder inner loops scale from there. I am not going to invent a 15s → 2s TDD figure for a latency I did not measure on this repo.

The claim that does not need a model: 1,101 MB → 109 MB, every time that cascade used to run. The 5-second clean build was the side effect. The win was killing the disk bottleneck so the next serial wait (NopTarget) became visible — and so a rebuild no longer wrote a gigabyte of copies.

This is rush-hour traffic inside the machine. Clearing it does not make the compiler faster. It means the verdict can arrive while the thought is still intact.

The build was the easy part

This was just compile and copy. Test suites on this nopCommerce tree often outlast the build — flaky retries, suites that get slower every sprint. I have not measured that on this repo yet.

If that much came back from copy and handshake alone, it is worth asking what is hiding in the test pipeline too.

Stop measuring this build only by the clock. Track CPU work, I/O footprint, and the critical path. The next serial blocker was already in the graph. The stopwatch was not going to introduce you.