Performance Regression Testing in the CI Pipeline
AI generated
60fps
ms
Performance · CI/CD · Lighthouse CI · Bundle Size
Performance Regression Testing in the CI Pipeline
Bundle size diffs and Lighthouse comparisons as a pre-merge gate

Performance regressions rarely come from one dramatic change, they accumulate from many small degradations that each stay under the radar. A CI pipeline that automatically compares bundle size and Lighthouse metrics against the base branch on every pull request makes this slow decline visible before it reaches the main branch and production.

16 min read Bundle Size Diff · Lighthouse CI · Statistical Significance GitHub Actions · GitLab CI · Magento 2 · Hyva Theme

1. Pre-Merge Gate vs. Post-Merge Monitoring

A pre-merge gate checks performance metrics before code lands on the main branch and blocks or flags the pull request when it detects a regression. Post-merge monitoring, via real user monitoring or synthetic checks against production, only catches regressions once they are already live. The two approaches are not mutually exclusive, they solve different problems: post-merge monitoring surfaces effects that only appear under real load, real networks, and real devices. A pre-merge gate prevents obvious regressions from ever reaching that stage in the first place.

The economic difference lies in the cost of fixing the problem. A regression caught in a pull request costs the author a few minutes of rework, while the same bug in production is often only noticed days later through declining conversion numbers or a CrUX drop, and then requires several follow-up commits, a revert, or a hotfix to resolve. For Magento and Hyva shops with frequent deployments, a pre-merge gate is therefore not an add-on but the first line of defense, while post-merge monitoring retains oversight of the actual user experience.

2. Anatomy of a Regression Test Pipeline

A working regression test pipeline needs three building blocks: a reproducible build for the pull request branch, a baseline measurement for the base branch (usually main), and a comparison step that puts both measurements side by side. Ideally the baseline is not re-measured on every PR, but produced once after each merge into main and stored as an artifact or in a small data store. This saves CI time and ensures every pull request is compared against the same stable reference value instead of a freshly measured, potentially noisy one.

The comparison step itself should run as its own job that takes the build and the baseline as input and produces a structured diff as output, for example in JSON format. This separation lets you reuse the same comparison logic for both bundle size and Lighthouse diffs and test it independently of the actual build process. It is also important that the pipeline uses the exact same build process for both the PR branch and the baseline, because different Node or PHP versions, different NODE_ENV values, or diverging Tailwind configuration will skew the comparison regardless of any real code changes.

3. Bundle Size Tracking per Pull Request

Bundle size tracking measures the size of the shipped JavaScript and CSS files after the build and compares them against the same build on the base branch. What matters is comparing not the raw uncompressed size but the size after gzip or brotli compression, because that matches the data volume actually transferred over the network. For Hyva themes this means concretely: web/tailwind/tailwind.css after the purge step and the generated Alpine.js bundles under pub/static/frontend/ are the relevant artifacts, not the unminified source code.

A simple, robust bash pattern checks out both branches in turn, builds them identically, and sums the file sizes per category. The diff is calculated as a percentage relative to the base size, not as an absolute byte difference, because a 5 KB delta on a 20 KB bundle means something entirely different than on a 500 KB bundle. A threshold of, say, 5 percent growth per category allows normal development to proceed while preventing unnoticed bloat from accumulating across many small pull requests.


#!/usr/bin/env bash
# bundle-size-diff.sh - Compare gzip bundle size between two git refs
set -euo pipefail

BASE_REF="${1:-origin/main}"
HEAD_REF="${2:-HEAD}"
THRESHOLD_PERCENT=5
BUNDLE_GLOB="pub/static/frontend/**/*.{js,css}"

measure_bundle_size() {
  local ref="$1"
  local worktree
  worktree="$(mktemp -d)"
  git worktree add --detach "$worktree" "$ref" >/dev/null
  ( cd "$worktree" && npm ci --silent && npm run build --silent )

  # Sum gzip-compressed size of all matching assets in bytes
  find "$worktree/pub/static/frontend" -type f \( -name "*.js" -o -name "*.css" \) \
    -exec gzip -c {} \; | wc -c

  git worktree remove --force "$worktree"
}

base_size="$(measure_bundle_size "$BASE_REF")"
head_size="$(measure_bundle_size "$HEAD_REF")"
delta=$(( head_size - base_size ))
percent=$(awk -v b="$base_size" -v d="$delta" 'BEGIN { printf "%.2f", (d / b) * 100 }')

echo "Base: ${base_size} bytes | Head: ${head_size} bytes | Delta: ${delta} bytes (${percent}%)"

if awk -v p="$percent" -v t="$THRESHOLD_PERCENT" 'BEGIN { exit !(p > t) }'; then
  echo "[FAIL] Bundle grew by ${percent}%, exceeds threshold of ${THRESHOLD_PERCENT}%"
  exit 1
fi
echo "[OK] Bundle size within threshold"

4. Commenting the Diff Automatically on the PR

Few developers go looking for a number buried in a CI log. A far more effective approach is an automatic PR comment that surfaces the bundle size and Lighthouse diff directly where code review already happens. The common technique for this is a sticky comment: instead of creating a new comment on every pipeline run, the bot looks for an existing comment with a unique marker (for example a hidden HTML comment <!-- perf-bot-marker -->) and updates it, rather than flooding the thread with repeated comments.

The comment should not just show the total number but break down where the size change comes from per file or chunk, including a link to the full Lighthouse report as an artifact. For teams running multiple storefronts or brands, a table per page type (home, category, product) is worth the extra effort, because a regression often affects only a single page template and an aggregated total number would hide that. Additionally setting a status: failure on the corresponding GitHub check makes the result usable by branch protection rules, not just visible in the comment.


# .github/workflows/performance-regression.yml
name: Performance Regression Check
on:
  pull_request:
    branches: [main]

jobs:
  bundle-and-lighthouse-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      # Restore cached base-branch build to avoid rebuilding main every run
      - name: Restore baseline artifact
        uses: actions/cache/restore@v4
        with:
          path: .baseline
          key: baseline-${{ github.event.pull_request.base.sha }}

      - name: Build PR branch
        run: npm ci && npm run build

      - name: Compare bundle size
        id: bundle
        run: bash ./ci/bundle-size-diff.sh origin/${{ github.base_ref }} HEAD >> "$GITHUB_OUTPUT"

      - name: Run Lighthouse CI on both branches
        run: npx @lhci/cli autorun --config=./lighthouserc.json

      - name: Post or update sticky PR comment
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: perf-bot-marker
          message: |
            ## Performance Regression Report
            ${{ steps.bundle.outputs.summary }}

5. Lighthouse Score Diffing Between Branches

Lighthouse score diffing goes a step beyond plain bundle tracking, because a smaller JavaScript file does not automatically mean a faster page. The pipeline builds both branches, deploys them to identically configured, temporary environments (for example Docker containers with the same seed dataset), and runs Lighthouse CI against the same URLs on both environments. What gets compared is not just the performance score but the individual metrics LCP, TBT (Total Blocking Time as a lab approximation of INP), and CLS, because a regression often affects only a single metric while the aggregated score masks it through improvements elsewhere.

An identical test environment for both runs is critical for reliable results: same CPU throttling, same network simulation, same number of repetitions. Lighthouse CI supports this through numberOfRuns in its config, running multiple passes per URL and using the median instead of a single value. For Magento shops with product pages that load varying numbers of images and variants depending on category, several representative page types should be tested, not just the homepage, since that is often the fastest and least representative page in the whole shop.


{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/catalog/womens-jackets.html",
        "http://localhost:3000/catalog/product/view/id/1284"
      ],
      "numberOfRuns": 5,
      "settings": {
        "throttlingMethod": "simulate",
        "throttling": {
          "cpuSlowdownMultiplier": 4,
          "rttMs": 150,
          "throughputKbps": 1600
        }
      }
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.85 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 300 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

6. Automatically Flagging Statistically Significant Changes

A naive number comparison between two Lighthouse runs produces useless results, because a single run already swings by several hundred milliseconds without any code change at all. The fix is to compare a distribution of several runs per branch rather than a single value, and only report a regression once the difference between the medians crosses a defined tolerance threshold, usually combining a relative percentage (for example a 10 percent LCP degradation) with an absolute minimum (for example at least 150 milliseconds), so that tiny percentage jumps on already very fast metrics do not falsely count as regressions.

A Node script that reads in two sets of Lighthouse JSON reports is a good place to implement this logic once and apply it consistently across metrics, instead of duplicating it in every workflow. It matters that the script allows different thresholds per metric, because CLS naturally fluctuates in a much smaller value range than LCP and therefore needs a different relative threshold. The result should be returned as structured JSON so it can feed both the PR comment and the pipeline's exit code.


// compare-lighthouse-reports.js - Flag statistically significant regressions
const fs = require('node:fs');

const METRIC_THRESHOLDS = {
  'largest-contentful-paint': { relativePercent: 10, absoluteMinMs: 150 },
  'total-blocking-time': { relativePercent: 15, absoluteMinMs: 50 },
  'cumulative-layout-shift': { relativePercent: 15, absoluteMinMs: 0.01 },
};

function median(values) {
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}

function loadMedianMetrics(reportPaths) {
  const runs = reportPaths.map((p) => JSON.parse(fs.readFileSync(p, 'utf8')));
  const result = {};
  for (const metric of Object.keys(METRIC_THRESHOLDS)) {
    result[metric] = median(runs.map((r) => r.audits[metric].numericValue));
  }
  return result;
}

function diffMetrics(baseReports, headReports) {
  const base = loadMedianMetrics(baseReports);
  const head = loadMedianMetrics(headReports);
  const regressions = [];

  for (const [metric, { relativePercent, absoluteMinMs }] of Object.entries(METRIC_THRESHOLDS)) {
    const delta = head[metric] - base[metric];
    const percent = (delta / base[metric]) * 100;

    // Flag only if BOTH the relative and absolute thresholds are exceeded
    if (percent > relativePercent && delta > absoluteMinMs) {
      regressions.push({ metric, base: base[metric], head: head[metric], percent: percent.toFixed(1) });
    }
  }
  return regressions;
}

const regressions = diffMetrics(process.argv.slice(2, 6), process.argv.slice(6, 10));
console.log(JSON.stringify({ regressions, hasRegression: regressions.length > 0 }, null, 2));
process.exit(regressions.length > 0 ? 1 : 0);

7. Taming Noise in CI Runners

CI runners on shared cloud infrastructure do not deliver constant compute performance: neighboring containers on the same host, shifting CPU generations, and variable network latency create scatter that is easily mistaken for a real regression in a single measurement. Teams that ignore this variance end up either with constant false positives that train the team to ignore warnings, or they lower the thresholds so far that real regressions slip through too. Either outcome erodes trust in the whole pipeline.

The most effective countermeasure is a combination of multiple repetitions per measurement (five Lighthouse runs instead of one), using the median instead of the mean since it is more robust against individual outliers, and dedicated, identically sized runners for performance-critical jobs instead of whatever instance happens to be cheapest. A warm-up run before the actual measurement also helps: it is not scored, but it neutralizes caching effects at the operating system and Docker level so the first measured run is not systematically slower than the following ones.

8. Balancing CI Runtime Cost Against Thoroughness

A full Lighthouse audit with five repetitions across several page types and both branches quickly costs several minutes of CI time per pull request, which adds up to significant cost and long wait times for an active team merging twenty PRs a day. Not every pull request needs the same level of scrutiny: a plain text change in CMS content or a translation file has no bearing on bundle size or rendering performance and does not need to run the full audit.

A practical approach is path-based filtering: the full regression test runs only when files under web/, Magento_Theme/templates, or JavaScript/CSS source directories have changed. For every other PR, a fast, cheap bundle size check without a full Lighthouse run is sufficient. On top of that, a label-triggered full audit that reviewers manually trigger with a perf-audit label, plus a nightly full run against main, catches drift over time that individual PR diffs might miss because the cumulative change from many small commits stays under the threshold each time.

9. Caching Build Artifacts and Strategies Compared

Caching build artifacts is the second big lever, alongside sampling, for keeping the pipeline fast. The node_modules cache, the Tailwind JIT cache, and above all the already-built baseline branch should be reused between pipeline runs instead of being regenerated on every PR. A cache key that combines the lockfile hash with the base branch commit SHA ensures the cache is invalidated exactly when dependencies or the baseline have actually changed, and not more often than that.

The table below compares common approaches to regression detection in the CI pipeline by detection point and typical weakness.

Strategy Detection Point Typical Weakness Recommendation
No automated check Only after deploy Regression reaches production unnoticed Introduce an automated pre-merge gate
Manual PR review Before merge, but unreliable Reviewer misses a few KB of bundle growth Automated bot comment with the diff
Post-merge RUM only Days to weeks after merge Users already affected, root cause hard to trace Pre-merge gate as complement, not replacement
Raw comparison of single Lighthouse runs Before merge, but noisy Flaky failures from CI runner variance Median of multiple runs plus tolerance band
Combined bundle and Lighthouse gate with sampling Before merge, targeted and weighted Requires upfront configuration effort Path filtering plus nightly full audit against main

For GitLab CI, the caching configuration looks technically different from GitHub Actions but follows the same principle: cache dependencies and build output under a stable key so repeated pipeline runs do not start from scratch.


# .gitlab-ci.yml - Cache dependencies and build output to keep the pipeline fast
performance-regression:
  stage: test
  image: node:20-alpine
  rules:
    - changes:
        - "src/app/design/frontend/**/web/**/*"
        - "src/app/design/frontend/**/*.phtml"
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
      - .npm/
    policy: pull-push
  variables:
    NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
  before_script:
    - npm ci --prefer-offline --cache .npm
  script:
    - npm run build
    - bash ./ci/bundle-size-diff.sh origin/main HEAD
    - npx @lhci/cli autorun --config=./lighthouserc.json
  artifacts:
    when: always
    paths:
      - .lighthouseci/
    expire_in: 14 days

Mironsoft

Performance engineering, CI/CD pipelines, and monitoring for Magento shops

Want to catch performance regressions automatically?

We set up bundle size tracking and Lighthouse diffing as a pre-merge gate in your CI pipeline, including robust thresholds against CI noise and a sampling strategy that keeps your pipeline fast.

CI Pipeline Audit

Analyze the existing pipeline and identify regression detection gaps

Gate Implementation

Set up bundle diffing, Lighthouse CI, and PR comments in production

Cost Optimization

Caching and sampling to keep the pipeline fast and affordable

10. Summary

Performance regression testing in the CI pipeline solves a problem that purely manual code review cannot structurally solve: the sum of many small, individually unremarkable degradations. A pre-merge gate that automatically compares gzip bundle size and core Lighthouse metrics against the base branch makes this decline visible before it reaches production. What matters for team buy-in is that the pipeline does not constantly raise false alarms from CI noise: a median across several runs, combined relative and absolute thresholds, and a sticky PR comment instead of a buried log entry ensure that developers actually trust the warnings and act on them.

Thoroughness and CI runtime are in direct tension, and that tension cannot be resolved by throwing more compute at it alone. Path-based filtering so pure content changes never trigger a full audit, cached baseline builds instead of repeated recomputation, and a nightly full audit as a safety net against cumulative drift keep the pipeline under a minute for most pull requests without losing signal on changes that actually matter.

Performance Regression Testing in the CI Pipeline - Key Takeaways

Pre-Merge Gate

Bundle size and Lighthouse comparison before merge catches regressions before they reach production. Complements, does not replace, post-merge monitoring.

Bundle Size Diff

Compare gzip size per category against the base branch, express the result as a percentage threshold, not an absolute byte difference.

Statistical Significance

Median across multiple numberOfRuns, combined relative and absolute thresholds against CI noise and false positives.

Cost & Caching

Path-based filtering, cached baseline builds, and a nightly full audit keep the pipeline fast and affordable.

11. FAQ: Performance Regression Testing in the CI Pipeline

1What is the difference between pre-merge and post-merge performance monitoring?
A pre-merge gate checks inside the pull request and blocks before merge. Post-merge monitoring only catches regressions once they are live. They complement each other, and the gate prevents the more expensive rework in production.
2How do you calculate the bundle size diff between two branches?
Build both branches identically, sum the gzip size of the assets, and compare it as a percentage relative to the base size, not as an absolute byte difference.
3How do you comment the diff automatically on the pull request?
Through a sticky comment with a unique marker that gets updated on every run instead of recreated. Tools like sticky-pull-request-comment handle this for GitHub Actions.
4How does Lighthouse score diffing between two branches work?
Deploy both branches to identical temporary environments, test them with Lighthouse CI, and compare individual metrics like LCP, TBT, and CLS as the median of several runs.
5Why are raw Lighthouse comparisons prone to false positives?
A single run swings by several hundred milliseconds purely from CI runner variance. A raw comparison of two single runs easily mistakes that scatter for a real regression.
6How do you avoid flaky failures from noisy CI runners?
Multiple repetitions and the median instead of a single value, dedicated instead of cheapest-available runners, plus an unscored warm-up run before the actual measurement.
7What thresholds make sense for bundle size and Lighthouse regressions?
Combine a relative percentage with an absolute minimum, for example 10 percent AND at least 150 ms delta, so tiny percentage jumps on fast metrics do not count falsely.
8When is it not worth running a full Lighthouse audit on every PR?
For pure content or translation changes with no impact on bundle or rendering. Path-based filtering triggers the full audit only on relevant directory changes.
9How do you keep build artifact caching fast in the CI pipeline?
Cache node_modules, the Tailwind JIT cache, and the baseline build under a key combining the lockfile hash and base branch SHA to avoid unnecessary invalidation.
10Should a performance regression test block the merge or just warn?
Block on clear breaches of the combined thresholds, since false positives are already filtered out. For borderline cases near the threshold, warn first with a manual override.