Performance in CI/CD
Performance in CI/CD
The Gate That Prevents Regressions
A performance regression caught before merge costs minutes. The same regression caught in production costs users, rankings, and engineering time. Every team agrees with this statement. Almost no team enforces it.
The reason is not laziness. The reason is that performance gates require infrastructure. You need a preview deployment for every pull request. You need Lighthouse running against that deployment with consistent settings. You need bundle size analysis comparing the PR branch against main. You need the results posted as a PR comment so reviewers see them. And you need branch protection rules that make these checks required, not advisory.
This chapter builds that infrastructure. Every subsequent chapter references it. When Chapter 3 introduces code splitting, the bundle size gate from this chapter catches the regression. When Chapter 5 introduces requestIdleCallback, the Lighthouse CI gate validates the INP improvement. The CI pipeline is the thread that runs through the entire book.
The diagram shows the sequence from pull request creation to merge. Every PR passes through three gates: Lighthouse CI validates Core Web Vitals, the bundle size check validates JavaScript weight, and the regression report compares against the baseline. A failure at any gate blocks the merge. This is the infrastructure the rest of the book relies on.
The Lighthouse CI Workflow
Lighthouse CI runs Lighthouse in a headless Chrome instance and compares results against defined budgets. The GitHub Actions workflow:
# .github/workflows/performance.yml
name: Performance Gates
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
cache-dependency-path: "ui/package-lock.json"
- name: Install dependencies
working-directory: ui
run: npm ci
- name: Build
working-directory: ui
run: npm run build
- name: Start preview server
working-directory: ui
run: |
npx serve dist -l 3000 &
sleep 3
- name: Run Lighthouse CI
working-directory: ui
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
- name: Upload Lighthouse results
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-results
path: ui/.lighthouseci/
The lhci autorun command reads configuration from lighthouserc.js:
// ui/lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
"http://localhost:3000/",
"http://localhost:3000/category/electronics",
"http://localhost:3000/product/sku-001",
"http://localhost:3000/checkout",
],
numberOfRuns: 3,
settings: {
preset: "desktop",
throttling: {
cpuSlowdownMultiplier: 4,
requestLatencyMs: 150,
downloadThroughputKbps: 1600,
uploadThroughputKbps: 750,
},
chromeFlags: "--no-sandbox --headless",
},
},
assert: {
assertions: {
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
interactive: ["error", { maxNumericValue: 3500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-blocking-time": ["error", { maxNumericValue: 300 }],
"resource-summary:script:size": [
"error",
{ maxNumericValue: 350000 }, // 350KB JS budget
],
"resource-summary:total:size": [
"error",
{ maxNumericValue: 800000 }, // 800KB total budget
],
},
},
upload: {
target: "temporary-public-storage",
},
},
};
The assertions block defines the performance budgets. These numbers come from the e-commerce platform’s field data baseline:
- LCP under 2,500ms: derived from the p75 target. The CI environment is faster than real user devices, so the CI budget is tighter than the field threshold to account for the gap.
- Total Blocking Time under 300ms: correlates with INP. TBT measures total main thread blocking time during load, which predicts how responsive the page will be to user interactions.
- CLS under 0.1: the “good” threshold. Any CLS above 0.1 in a controlled CI environment will be worse in the field.
- JavaScript under 350KB: the budget that keeps LCP under 2.5s on 4G with the e-commerce platform’s current TTFB. This budget was determined by testing: every 50KB increase in JS transfer size added ~300ms to LCP on the Moto G Power profile.
- Total transfer under 800KB: prevents image and font bloat from accumulating beneath individual budgets.
These are not round numbers chosen for aesthetics. They are calibrated against the field data from Chapter 1.
The Bundle Size Gate
Lighthouse CI catches total resource size, but it runs against the built output and does not provide granular per-bundle tracking. size-limit fills this gap by measuring individual entry points and chunks.
// ui/package.json (relevant section)
{
"size-limit": [
{
"path": "dist/_astro/index.*.js",
"limit": "85 kB",
"gzip": true
},
{
"path": "dist/_astro/product-*.js",
"limit": "45 kB",
"gzip": true
},
{
"path": "dist/_astro/checkout-*.js",
"limit": "60 kB",
"gzip": true
},
{
"path": "dist/_astro/vendor-*.js",
"limit": "120 kB",
"gzip": true
}
]
}
The GitHub Actions job:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
cache-dependency-path: "ui/package-lock.json"
- name: Install dependencies
working-directory: ui
run: npm ci
- name: Build
working-directory: ui
run: npm run build
- name: Check bundle sizes
working-directory: ui
run: npx size-limit
- name: Size limit report
if: github.event_name == 'pull_request'
uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
directory: ui
The size-limit-action posts a PR comment showing the size of each bundle on the PR branch compared to main:
📦 Size Limit Report
| Path | Size (main) | Size (PR) | Change |
|-----------------------------|-------------|------------|----------|
| dist/_astro/index.*.js | 78.2 kB | 82.4 kB | +4.2 kB |
| dist/_astro/product-*.js | 41.8 kB | 41.8 kB | 0 |
| dist/_astro/checkout-*.js | 54.1 kB | 54.1 kB | 0 |
| dist/_astro/vendor-*.js | 112.3 kB | 118.7 kB | +6.4 kB |
A +4.2 kB increase in the index bundle is a yellow flag. If it pushes past the 85 kB limit, the check fails and blocks the merge. The developer sees exactly which bundle grew, which helps them trace the cause: a new dependency, a removed code split, or an accidental barrel import.
Branch Protection Configuration
The gates are useless if they are advisory. Configure branch protection to make them required:
- Repository Settings > Branches > Branch protection rules > Add rule
- Branch name pattern:
main - Check “Require status checks to pass before merging”
- Add required status checks:
lighthouse(the Lighthouse CI job)bundle-size(the size-limit job)
- Check “Require branches to be up to date before merging”
With this configuration, a PR cannot merge if either gate fails. The developer must fix the performance regression before the code reaches main. The regression is caught in minutes, not days.
The cost: every PR now runs a full build plus Lighthouse CI (adds ~3-4 minutes to the pipeline) and a bundle size check (adds ~1-2 minutes). For the e-commerce platform, the total pipeline time increased from 8 minutes to 13 minutes. This is cheaper than a single production performance incident.