CI/CD Pipeline Engineering

Where the Minutes Actually Go

The dashboard says the average build takes 12 minutes. The engineers say it takes most of an hour. Both are reading real numbers, and the gap between them is the whole problem.


The Problem at Scale

A pipeline duration is not one number, it is five numbers added together, and they have almost nothing in common. They have different causes, different owners, different fixes, and wildly different sizes.

  queue     waiting for compute to become available
  setup     checkout, dependency restore, image pull, tool install
  work      compiling, testing, packaging: the part you meant to do
  human     waiting for a review or an approval
  rework    re-runs, flake retries, and push-fix-push cycles

Almost every CI dashboard reports the sum, or worse, reports only work plus setup and calls it the build time. That is why the dashboard and the engineers disagree, and neither is lying.

You cannot fix an aggregate. Halving the wrong bucket is invisible; halving the right one is transformative; and the only way to tell them apart is to measure them separately before touching anything.

KEY CONCEPT

The most common measurement error in CI is that the reported duration starts when the job starts, which means queue time is excluded by construction. Every minute spent waiting for a runner is real to the person waiting and absent from the number the platform team reports, so the two groups end up with an honest disagreement about how slow the pipeline is. Compute duration from the time the run was created, not from the time it started.


How It Works

A real decomposition

Here is one change, measured end to end, on a pipeline whose dashboard reports 12 minutes.

push                                              t+0
  queue, waiting for a runner                   4m 12s
  checkout and dependency restore               2m 48s
  compile                                       3m 30s
  unit tests                                    2m 06s
  integration tests (parallel branch)           9m 18s
  ---------------------------------------------------
  first signal (lint, parallel)                 5m 40s
  all required checks green                    21m 54s   <- not 12
  waiting for a reviewer                        6h 12m   <- the real number
  merge queue                                  24m 30s
  deploy                                       11m 00s
  ---------------------------------------------------
  commit to production                        ~7h 10m

Three findings fall out of this and none of them are visible in the aggregate.

Queue plus setup is seven minutes before any useful work begins, which is 32 percent of the time to green. That is a fleet and caching problem, not a test problem.

The dashboard reports 12 minutes and the required checks take 22. The dashboard is measuring the job, not the change.

The human wait is larger than everything else combined by a factor of seventeen. Any conversation about making this pipeline faster that does not mention review latency is a conversation about the small half.

That last point is uncomfortable because review latency is not the platform team's to fix, which is exactly why it stays unmeasured. Measure it anyway. It changes what the organisation chooses to work on.

The five buckets and their fixes

Queue. Jobs waiting for a runner. Caused by fleet capacity, concurrency limits, or a scale-up that is slower than the arrival rate. Fixed in Module 4. Grows nonlinearly: a fleet at 85 percent utilisation queues occasionally, and at 95 percent it queues constantly.

Setup. Everything before the work. Checkout, dependency restore, container image pull, toolchain install. Caused by cold caches, large images and repository size. Fixed in Modules 2 and 4. Its distinctive property is that it is paid per job, so splitting the pipeline multiplies it.

Work. The compiling and testing. Fixed by parallelism, test selection and caching, in Modules 2 and 3. This is the bucket everybody assumes is the problem, and it usually is not the largest one.

Human. Review and approval. Not a compute problem at all. Reduced by smaller changes, clearer ownership and review expectations, and smaller changes are themselves a consequence of a faster pipeline, from lesson one.

Rework. Runs that had to happen again. A flake retry, a fix for something the pipeline could have told you sooner, a re-run of a whole pipeline for one failed job. Fixed in Modules 3 and 7, and often the easiest large win because it is pure waste rather than a tradeoff.

Why the buckets are usually measured wrong

Three specific traps.

Duration from job start. Excludes queue, which is the bucket most likely to be growing.

Averaging over all runs. Runs on the default branch are not runs anyone waits for, and they are usually the majority, so they dominate the average and hide the pull request experience.

Measuring the pipeline instead of the change. A change that took four pushes to go green consumed four pipelines. The engineer experienced one long wait, not four short ones, and the per-run number reports the short one.

The unit that matters is the change, from first push to merge, including every re-run it took.


Building and Operating It

Compute queue time explicitly, since nothing gives it to you.

# createdAt is when the run was requested, startedAt when a runner
# picked it up. The difference is queue, and it is the number that
# is missing from every default dashboard.
gh run list --limit 200 --json createdAt,startedAt,conclusion,event \
  | jq -r '.[] | select(.event=="pull_request")
      | ((.startedAt|fromdate) - (.createdAt|fromdate)) as $q
      | "\($q)s queue"' \
  | sort -n | tail -20

Measure per change rather than per run.

# All runs for one head SHA lineage on a pull request branch. Total
# elapsed from the first push to the last green is what the engineer
# actually experienced.
gh run list --branch "$BRANCH" --json createdAt,updatedAt,conclusion \
  | jq -r 'sort_by(.createdAt) |
      "first push: \(.[0].createdAt)   last result: \(.[-1].updatedAt)   runs: \(length)"'

And do not skip the human bucket.

# Time from ready-for-review to first review. Usually the largest
# single number in the whole system, and usually nobody owns it.
gh pr list --state merged --limit 100 \
  --json createdAt,reviews,mergedAt,number \
  | jq -r '.[] | select(.reviews|length > 0)
      | ((.reviews[0].submittedAt|fromdate) - (.createdAt|fromdate))/3600
      | "\(. * 100 | round / 100) h to first review"'
WAR STORY

A platform team spent a quarter cutting their test suite from 18 minutes to 9, which was real engineering and a genuine improvement to the work bucket. Delivery throughput did not change. When they finally instrumented the whole path they found a median of five and a half hours waiting for review and 26 percent of runs being re-run at least once because of an integration test that failed roughly one time in six. The quarter of work was spent on the third largest bucket, and both larger ones had been visible in the data the whole time, in the sense that nobody had ever looked.


Tradeoffs and Decision Framework

BucketTypical shareFix lives inTractability
Queue5 to 30 percentModule 4, fleet capacityHigh, costs money
Setup10 to 35 percentModules 2 and 4, caching and imagesHigh, costs effort
Work30 to 60 percentModules 2 and 3, parallelism and selectionMedium
HumanOften the largestNot a platform fixLow, organisational
Rework5 to 40 percentModules 3 and 7, flake and reliabilityHigh, pure waste

Attack in order of size multiplied by tractability, not in order of what is interesting. Rework first whenever it is material, because it is waste rather than a tradeoff and removing it costs nobody anything. Setup and queue next, since both are mechanical. Work third, since it is the one that requires real engineering. Human always gets reported, even though you cannot fix it, because the organisation cannot prioritise what it cannot see.

Default: instrument the five buckets separately per change rather than per run, compute duration from creation rather than from job start, exclude the default branch from the developer-facing percentile, and report the human bucket even though it is not yours.


Failure Modes and Common Mistakes

Reporting one duration number. It is a sum of five unrelated things and it hides which one is growing.

Starting the clock at job start. Queue time disappears exactly when the fleet is under pressure and you most need to see it.

Averaging pull requests together with the default branch. The default branch dominates and nobody is waiting for it.

Measuring runs instead of changes. A change that needed four pushes shows up as four fast runs and one long afternoon.

Ignoring the human bucket because it is not a platform problem. It is frequently larger than everything you can fix, and leaving it unmeasured guarantees it stays that way.

Optimising the interesting bucket. The test suite is the fun problem and the re-run rate is usually the bigger one.

KNOWLEDGE CHECK

Your CI dashboard reports a stable 11 minute average build over six months. Engineers insist the pipeline has gotten much worse over that period. Assuming both are looking at real data, what is the most likely explanation?

INTERVIEW QUESTION

Your dashboard and your engineers disagree about how slow CI is. How do you find out who is right?