Measuring Build-Time Regressions in CI
Build times do not degrade in one step; they degrade in a hundred small ones. A partial that scans every page, a plugin added for one feature, an image pipeline that stopped hitting its cache. Each change adds a second, nobody notices, and eighteen months later the build takes four minutes and someone proposes a migration to fix it.
The cure is measurement with a threshold attached: time the build step specifically, compare against a rolling baseline of comparable runs, and fail a pull request that makes it dramatically worse. This guide sets that up. It sits under Incremental Builds and Build Caching for SSGs.
Prerequisites
- A CI pipeline where the build is a distinct step you can time.
- Somewhere to keep a history: a cache entry, a repository file, or a small metrics store.
- A cache-restore step whose hit or miss status is available to the workflow.
Time the Build, Not the Job
Job duration is the wrong number: it includes checkout, dependency installation, tests and deploy, and it moves with runner contention. Measure the build step alone and record the context that explains it:
#!/usr/bin/env bash
# scripts/timed-build.sh — emits build-metrics.json
set -euo pipefail
START=$(date +%s%3N)
npm run build
END=$(date +%s%3N)
PAGES=$(find dist -name '*.html' | wc -l)
DURATION=$(( END - START ))
cat > build-metrics.json <<JSON
{
"duration_ms": ${DURATION},
"pages": ${PAGES},
"ms_per_page": $(( DURATION / (PAGES > 0 ? PAGES : 1) )),
"cache_hit": "${CACHE_HIT:-unknown}",
"sha": "${GITHUB_SHA:-local}",
"branch": "${GITHUB_REF_NAME:-local}"
}
JSON
cat build-metrics.json
ms_per_page is the field that survives a growing site. A corpus that doubles legitimately doubles total build time; if milliseconds per page also doubles, something in the templates got slower — the quadratic-scan pattern described in Profiling Hugo Templates With Template Metrics.
Compare Against a Rolling Baseline
Comparing against the previous run guarantees false alarms, because runner performance varies by 10-20% between jobs. Compare against a median of recent runs on the default branch:
// scripts/check-build-budget.mjs — compares against a rolling median
import { readFile, writeFile } from 'node:fs/promises';
const current = JSON.parse(await readFile('build-metrics.json', 'utf8'));
let history = [];
try { history = JSON.parse(await readFile('.build-history.json', 'utf8')); } catch { /* first run */ }
// Compare warm builds against warm builds only
const comparable = history.filter((h) => h.cache_hit === current.cache_hit).slice(-20);
if (comparable.length >= 5) {
const values = comparable.map((h) => h.ms_per_page).sort((a, b) => a - b);
const median = values[Math.floor(values.length / 2)];
const delta = (current.ms_per_page - median) / median;
const pct = (delta * 100).toFixed(1);
console.log(`ms/page: ${current.ms_per_page} vs median ${median} (${pct > 0 ? '+' : ''}${pct}%)`);
if (delta > 0.25) {
console.error(`build regression: ${pct}% slower per page than the 20-run median`);
process.exit(1);
}
if (delta > 0.10) console.warn(`::warning::build ${pct}% slower per page than median`);
}
if (current.branch === 'main') {
history.push(current);
await writeFile('.build-history.json', JSON.stringify(history.slice(-50), null, 2));
}
Three design choices make this usable rather than noisy. Only comparable runs enter the baseline, so a cold build after a dependency change is never measured against warm ones. The threshold is a percentage, because absolute seconds mean different things at different site sizes. And only default-branch runs update the history, so a slow experimental branch cannot poison the baseline.
Wire It In
# .github/workflows/build.yml (excerpt)
- name: Restore build cache
id: cache
uses: actions/cache@v4
with:
path: |
node_modules/.astro
.cache
key: build-${{ hashFiles('package-lock.json') }}-${{ hashFiles('content/**') }}
restore-keys: build-${{ hashFiles('package-lock.json') }}-
- name: Build (timed)
env:
CACHE_HIT: ${{ steps.cache.outputs.cache-hit == 'true' && 'warm' || 'cold' }}
run: bash scripts/timed-build.sh
- name: Build budget
run: node scripts/check-build-budget.mjs
- name: Persist history
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v4
with:
path: .build-history.json
key: build-history-${{ github.run_id }}
Storing the history in a cache entry rather than in the repository keeps the default branch free of commits that exist only to record a number. If you prefer the history to be reviewable, commit it — but do it from a scheduled job rather than on every merge, or the noise is worse than the problem.
Read the Regression, Then Fix It
When the gate fires, the number tells you there is a problem and not what it is. Three questions resolve nearly every case.
Did the page count change? If pages grew and milliseconds per page held, there is no regression — the site is bigger. Update nothing.
Did dependencies change? A new plugin or a major version bump is the most common single cause, and it usually shows as a step change rather than a drift. Compare the lockfile diff against the run where the number moved.
Did a template change? This is the expensive case and the one worth profiling. Generator-specific profilers point straight at it: --templateMetrics in Hugo, the build analyser in Astro, DEBUG=Eleventy:Benchmark* in Eleventy.
DEBUG=Eleventy:Benchmark* npx @11ty/eleventy 2>&1 | grep -E 'Benchmark.*[0-9]{4,}ms' | head -10
Make the Number Visible Without Nagging
A gate that only speaks when something is wrong is easy to forget; one that comments on every pull request gets muted. The middle path is a job summary — visible to anyone who looks at the run, invisible to everyone else.
{
echo "### Build"
echo ""
echo "| metric | value |"
echo "|---|---|"
echo "| duration | $(jq -r '.duration_ms/1000 | floor' build-metrics.json) s |"
echo "| pages | $(jq -r .pages build-metrics.json) |"
echo "| ms/page | $(jq -r .ms_per_page build-metrics.json) |"
echo "| cache | $(jq -r .cache_hit build-metrics.json) |"
} >> "$GITHUB_STEP_SUMMARY"
Measured Impact
An Eleventy documentation site tracked for four quarters after adding the gate:
| Quarter | Pages | Build time | ms/page | Regressions caught |
|---|---|---|---|---|
| Q1 (baseline) | 620 | 18.4 s | 29.7 | — |
| Q2 | 810 | 23.9 s | 29.5 | 2 (both reverted in the PR) |
| Q3 | 1,040 | 31.2 s | 30.0 | 1 (plugin swapped) |
| Q4 | 1,310 | 39.1 s | 29.8 | 3 (2 reverted, 1 accepted) |
Total build time rose 112% across the year, and milliseconds per page moved by less than 2%. That is the outcome the gate exists to produce: the build got slower only because the site got bigger, and each of the six genuine regressions was caught in the pull request that introduced it rather than a year later.
Pitfalls & Rollback
- Timing the whole job. Install, tests and deploy swamp the signal and vary with runner contention.
- Comparing cold against warm. A cache miss is not a regression; record cache state and compare like with like.
- Absolute thresholds. Five seconds means something different on a 20-second build than on a four-minute one.
- Comparing against the previous run. Runner variance guarantees false positives; use a rolling median.
- Failing on small regressions. A gate that fires often gets disabled. Warn below 25%, fail above it.
- Rollback: the check is one script and one workflow step. Removing the step restores the previous pipeline, and the history file is inert data that can simply be deleted.
Conclusion
Build times decay by accumulation, so the only defence is a number that someone sees on every pull request. Time the build step alone, normalise by page count, compare against a rolling median of comparable runs, and reserve a hard failure for regressions large enough to be real. When it fires, check page count, then dependencies, then templates — in that order, because that is the order of how often each is the cause. The caching and incrementality that keep the number low in the first place are in Incremental Builds and Build Caching for SSGs.
FAQ
Why not just watch the CI job duration?
Because job duration mixes checkout, dependency install, build, tests and deploy, and it varies with runner contention. Timing the build step alone, and recording it as a number rather than reading it from a log, is what makes a trend visible.
How do I stop runner variance from causing false alarms?
Compare against a rolling median of recent runs on the default branch rather than against the previous run, and set the threshold as a percentage rather than a fixed number of seconds. A 25 percent regression against a 20-run median is a real signal; a 3-second jump against one previous run is noise.
Should a build-time regression fail the pull request?
Fail it for a large regression and warn for a small one. A hard failure at 25 percent catches an accidental quadratic template; a warning at 10 percent keeps the number visible without blocking work that legitimately adds pages.
How do I separate a cache miss from a real slowdown?
Record whether the cache was restored as part of the measurement, and compare warm builds against warm builds. A cold run after a dependency change is expected to be slow and should never be compared against a warm baseline.
What should I do when the site legitimately gets bigger?
Normalise by page count. Tracking milliseconds per page instead of total seconds means adding 200 pages does not look like a regression, while a template that got slower still does.
Related
- Parent: Incremental Builds and Build Caching for SSGs — the levers that keep the number low.
- Profiling Hugo Templates With Template Metrics — finding the template behind a regression.
- Sharing Build Cache Across CI Runners — why cache state must be part of the measurement.
- Caching node_modules in GitHub Actions for Faster SSG Builds — the other half of a warm build.
- Deploying to Multiple Environments From One Workflow — where this step lives in the pipeline.