Web Engineering ·

Web Performance Budgets in Practice: Core Web Vitals, Bundle Analysis, and Automated Regression Detection

A practical guide to setting meaningful web performance budgets based on Core Web Vitals, integrating bundle size analysis into CI/CD, and using Lighthouse CI for automated regression detection. Includes TypeScript examples for custom performance monitoring and real-user monitoring setup.

Web Performance Budgets in Practice: Core Web Vitals, Bundle Analysis, and Automated Regression Detection

Most teams that care about performance do so reactively. A slow page ships, a user complains, someone files a ticket, and an engineer spends a week chasing a regression that has been accumulating for months. Performance budgets exist to break this cycle. Not by making performance fast, but by making regressions visible before they reach users.

This guide covers how to set budgets that mean something, how to enforce them in CI, and how to build the feedback loops that prevent regressions from going undetected.

Why Budgets Fail Before You Set Them

The most common failure mode in performance budgeting is setting numbers in a vacuum. An LCP budget of 2.5 seconds means nothing if your p75 field data is already 4.1 seconds. A bundle budget of 200KB means nothing if your real users are on 50Mbps connections where 600KB loads fine.

Two foundational questions before you write a single budget config:

What does your field data actually say? Google Search Console’s Core Web Vitals report gives you real CrUX (Chrome User Experience Report) data for your domain, segmented by mobile and desktop. This is where you start. If your p75 LCP on mobile is 3.8 seconds, your budget is not 2.5 seconds out of the gate. It is 3.5 seconds for the next sprint, 3.0 seconds the sprint after.

What are your users’ network and device conditions? A logistics SaaS used by warehouse workers on cellular has different constraints than a developer tool used on fiber. Calibrate your synthetic test profiles to your actual user population, not the Lighthouse default.

Once you have baseline field data, you can set budgets with direction: targets that tighten over time rather than aspirational numbers that nobody believes.

Core Web Vitals as Budget Anchors

The three Core Web Vitals are the right starting point for performance budgets because they are the metrics Google uses for search ranking and because they map directly to user experience dimensions.

LCP (Largest Contentful Paint) measures when the main content is visible. Google’s thresholds: good is under 2.5s, needs improvement is 2.5s to 4.0s, poor is above 4.0s. For a budget, target p75 LCP at or below 2.5s on mobile.

INP (Interaction to Next Paint) replaced FID as the interactivity metric in 2024. It measures the worst interaction latency across a session. Good is under 200ms, needs improvement is 200ms to 500ms, poor is above 500ms. INP is the metric most directly tied to JavaScript execution cost, which makes it the one most likely to regress as your application grows.

CLS (Cumulative Layout Shift) measures visual stability. Good is under 0.1, needs improvement is 0.1 to 0.25, poor is above 0.25. CLS regressions usually come from images without explicit dimensions, dynamic content injection above the fold, or late-loading web fonts.

Collecting Field Data with the web-vitals Library

Synthetic testing tells you what performance looks like under controlled conditions. Field data tells you what it looks like for your actual users. You need both. The web-vitals library makes field collection straightforward:

import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";

type MetricRating = "good" | "needs-improvement" | "poor";

interface VitalReading {
  name: string;
  value: number;
  rating: MetricRating;
  navigationType: string;
  id: string;
}

function reportVital(metric: {
  name: string;
  value: number;
  rating: MetricRating;
  navigationType: string;
  id: string;
}): void {
  const reading: VitalReading = {
    name: metric.name,
    value: Math.round(metric.name === "CLS" ? metric.value * 1000 : metric.value),
    rating: metric.rating,
    navigationType: metric.navigationType,
    id: metric.id,
  };

  // Batch sends to avoid blocking the page
  const body = JSON.stringify(reading);
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", { method: "POST", body, keepalive: true });
  }
}

// Call at app initialization
export function initVitalsReporting(): void {
  onLCP(reportVital);
  onINP(reportVital);
  onCLS(reportVital);
  onFCP(reportVital);
  onTTFB(reportVital);
}

On the server, store these readings with a timestamp, URL path, connection type if available, and device category. After two weeks you have enough p75 data to set a meaningful baseline per route. A checkout page and a marketing homepage have different performance profiles and should have different budgets.

Per-Route Budgets

A single site-wide LCP budget misses the fact that a dashboard page with complex data tables has different constraints than a landing page. Store budgets as configuration:

interface RouteBudget {
  path: string;
  lcp: number;   // milliseconds
  inp: number;   // milliseconds
  cls: number;   // unitless, multiplied by 1000 for storage
  fcp: number;   // milliseconds
  ttfb: number;  // milliseconds
}

const performanceBudgets: RouteBudget[] = [
  {
    path: "/",
    lcp: 2500,
    inp: 200,
    cls: 100, // 0.1 * 1000
    fcp: 1800,
    ttfb: 800,
  },
  {
    path: "/dashboard",
    lcp: 3000,
    inp: 300,
    cls: 100,
    fcp: 2000,
    ttfb: 800,
  },
  {
    path: "/checkout",
    lcp: 2000,
    inp: 150,
    cls: 50,
    fcp: 1500,
    ttfb: 600,
  },
];

export function checkBudget(
  path: string,
  metric: { name: string; value: number }
): { passed: boolean; budget: number | null } {
  const budget = performanceBudgets.find(
    (b) => path === b.path || path.startsWith(b.path + "/")
  );

  if (!budget) return { passed: true, budget: null };

  const key = metric.name.toLowerCase() as keyof Omit<RouteBudget, "path">;
  const threshold = budget[key];

  if (typeof threshold !== "number") return { passed: true, budget: null };

  return {
    passed: metric.value <= threshold,
    budget: threshold,
  };
}

Bundle Size Budgets in CI

JavaScript bundle size is one of the most reliable leading indicators of LCP and INP regression. A 100KB increase in your main chunk does not always cause a performance regression, but it increases the probability. Catching the increase in CI forces the team to make the tradeoff explicit.

Analyzing with source-map-explorer

Before you can set a bundle budget, you need to know where your bytes are going. source-map-explorer generates a treemap from your sourcemaps:

# Build with sourcemaps
npx source-map-explorer 'dist/static/js/*.js' --html bundle-report.html

This immediately surfaces large dependencies that should not be in your main chunk. Common culprits: date libraries (moment.js instead of date-fns), full lodash instead of individual imports, unoptimized icon libraries importing every icon, and development-only code that was never tree-shaken.

Enforcing Limits with bundlesize

Once you know your baseline, bundlesize can fail CI when a bundle exceeds a threshold:

// bundlesize.config.json
{
  "files": [
    {
      "path": "./dist/static/js/main.*.js",
      "maxSize": "180 kB"
    },
    {
      "path": "./dist/static/js/vendor.*.js",
      "maxSize": "250 kB"
    },
    {
      "path": "./dist/static/css/main.*.css",
      "maxSize": "30 kB"
    }
  ]
}

In your CI pipeline:

# .github/workflows/performance.yml
- name: Build
  run: npm run build

- name: Check bundle size
  run: npx bundlesize

- name: Upload bundle report
  uses: actions/upload-artifact@v4
  with:
    name: bundle-report
    path: bundle-report.html

Set your initial thresholds 10-15% above current baseline. This gives you room for normal variation while still catching regressions. Tighten them quarterly as you improve.

Webpack Bundle Analyzer for Deeper Investigation

When a bundle size check fails, you need to know why. Webpack Bundle Analyzer generates an interactive visualization that makes dependency attribution obvious:

// next.config.ts
import { NextConfig } from "next";

const withBundleAnalyzer =
  process.env.ANALYZE === "true"
    ? require("@next/bundle-analyzer")({ enabled: true })
    : (config: NextConfig) => config;

const config: NextConfig = {
  // your config
};

export default withBundleAnalyzer(config);

Run with ANALYZE=true npm run build locally whenever a bundle check fails. The interactive treemap shows exactly which package caused the regression and whether it is in the right chunk.

Lighthouse CI for Automated Regression Detection

Bundle size checks catch byte regressions before they ship. Lighthouse CI catches rendered performance regressions by running a full Lighthouse audit against your deployed preview environment on every pull request.

Setting Up Lighthouse CI

npm install --save-dev @lhci/cli

Create a lighthouserc.js at the repo root:

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: [
        "http://localhost:3000/",
        "http://localhost:3000/dashboard",
        "http://localhost:3000/pricing",
      ],
      numberOfRuns: 3,
      startServerCommand: "npm run start",
      startServerReadyPattern: "ready on",
    },
    assert: {
      assertions: {
        "categories:performance": ["error", { minScore: 0.8 }],
        "first-contentful-paint": ["error", { maxNumericValue: 2000 }],
        "largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
        "total-blocking-time": ["error", { maxNumericValue: 300 }],
        "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
        "interactive": ["warn", { maxNumericValue: 3800 }],
        "uses-optimized-images": ["warn", { maxLength: 0 }],
        "unused-javascript": ["warn", { maxLength: 0 }],
      },
    },
    upload: {
      target: "temporary-public-storage",
    },
  },
};

The numberOfRuns: 3 setting is important. Single Lighthouse runs have high variance, especially in CI environments with shared compute. Taking the median of three runs gives you stable results.

GitHub Actions Integration

# .github/workflows/performance.yml
name: Performance Checks

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"

      - run: npm ci

      - run: npm run build

      - name: Run Lighthouse CI
        run: npx lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

      - name: Check bundle sizes
        run: npx bundlesize

With LHCI_GITHUB_APP_TOKEN configured, Lighthouse CI posts a comment to the PR with score comparisons and a link to the full report. Reviewers see score deltas without leaving GitHub. A failing Lighthouse assertion blocks merge.

What Lighthouse CI Does Not Cover

Lighthouse runs against a cold, single-user load. It does not simulate:

  • Cache warming effects on repeat visits
  • Concurrent user load impacting server response times
  • Real device CPU and memory constraints (the CPU throttling is a simulation)
  • Geographic latency to your actual user base

Use Lighthouse CI to catch clear regressions and establish a floor. Use field data from your RUM setup to understand actual user experience. The two complement each other rather than replace each other.

Real-User Monitoring vs Synthetic Testing

The relationship between RUM and synthetic testing is worth making explicit because teams often treat them as alternatives.

DimensionSynthetic (Lighthouse CI)Real-User Monitoring
When it runsEvery PRContinuously in production
Data sourceControlled lab environmentActual user sessions
CatchesRegressions before deployRegressions in production
VariabilityLow (controlled)High (device, network, geography)
Latency to detectionMinutesHours to days for p75 stabilization
CoverageURLs you configureEvery URL users visit
CostCI compute timeStorage and ingestion per session

The practical split: use Lighthouse CI as the gate that prevents obvious regressions from deploying. Use RUM to monitor p75 trends over time and catch the slow degradations that synthetic tests miss because they only test a handful of URLs under ideal conditions.

A dashboard route that degrades from 2.8s to 3.6s LCP over three months will not trip your Lighthouse budget if your budget is set at 3.0s. Your field data will show the trend before it becomes a crisis.

Building a Minimal RUM Pipeline

If you are not ready for a third-party RUM tool, a minimal self-hosted pipeline with the web-vitals library, a lightweight ingestion endpoint, and a Postgres + Grafana stack can cover the basics:

// app/api/vitals/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";

interface VitalPayload {
  name: string;
  value: number;
  rating: "good" | "needs-improvement" | "poor";
  navigationType: string;
  id: string;
}

export async function POST(req: NextRequest): Promise<NextResponse> {
  const payload = (await req.json()) as VitalPayload;

  const url = req.headers.get("referer") ?? "unknown";
  const userAgent = req.headers.get("user-agent") ?? "unknown";

  await db.execute(
    `INSERT INTO vitals (name, value, rating, navigation_type, metric_id, url, user_agent, recorded_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
     ON CONFLICT (metric_id) DO NOTHING`,
    [
      payload.name,
      payload.value,
      payload.rating,
      payload.navigationType,
      payload.id,
      url,
      userAgent,
    ]
  );

  return NextResponse.json({ ok: true });
}

The ON CONFLICT (metric_id) DO NOTHING guard handles the case where the page sends a metric twice, which happens because INP and CLS are reported incrementally and again on page hide. The id field from the web-vitals library is stable within a page session.

Store a week of data and query p75 per route per day. Alert when the 7-day rolling p75 increases more than 15% over the prior 7-day window.

Tradeoffs: Where to Invest First

InvestmentCostSignal TypeBest For
Lighthouse CI assertionsLow (1-2 hours to set up)Synthetic, pre-deployPreventing obvious regressions
Bundle size checksVery low (30 min)Leading indicatorCatching JS growth early
RUM ingestion endpointMedium (4-8 hours)Field, post-deployMonitoring real user experience
Third-party RUM (Datadog, Sentry)Medium (vendor cost)Field, post-deployFull-fidelity session data without infra overhead
Per-route budget enforcementLow-medium (ongoing)BothRouting investigations to the right place
Performance regression alertsMediumFieldCatching slow degradations over time

Start with bundle size checks and Lighthouse CI. Both take an afternoon to configure and immediately give you regression detection. Add RUM once you have enough traffic to make p75 data statistically meaningful (roughly 100+ sessions per route per day).

Production Considerations

Baseline before budgeting. Run Lighthouse CI for two weeks before setting assertions. Use the median score from those runs as your floor, not Google’s “good” threshold. Aspirational budgets that fail on day one create friction without trust.

Separate error vs warn thresholds. Not every budget violation should block a deploy. Use error for metrics that directly affect user experience at a significant level (LCP > 4s, CLS > 0.25) and warn for metrics you want visibility into without blocking velocity.

Test on mobile profiles. Lighthouse’s default mobile simulation uses a 4x CPU slowdown and simulated 4G. This is more representative of a median global user than a desktop test. If your users are primarily desktop, adjust the profile, but do not remove throttling entirely. CI machines are fast. Your users are not.

Cache Lighthouse CI results. If you are running Lighthouse against a preview deployment, the first run may be slower due to cold starts. Configure startServerReadyPattern to wait for the server to warm up, and discard the first run’s numbers in your median calculation.

Version your performance budget config. The lighthouserc.js and bundlesize.config.json files belong in version control. Budget changes should require a PR, not a direct edit to a config server. This prevents budget targets from drifting downward to accommodate a regression rather than fixing the regression.

Correlate deploys with RUM trends. Tag your vitals ingestion records with a build ID or git SHA. When a field data regression appears, you can bisect to the deploy that caused it within minutes instead of hours.

Web Workers for INP-sensitive paths. If your INP budget is tight (under 200ms) and you have heavy computation on user interaction, move that computation off the main thread. The main thread budget for a 200ms INP is roughly 50ms of JavaScript execution time when you account for browser overhead. Anything heavier than that belongs in a Worker.

Building the Feedback Loop

The technical setup is the easier half. The harder half is making performance regressions visible enough that the team catches them early.

A performance budget that lives only in CI and never surfaces in code review has limited impact. The patterns that work:

Fail PR checks loudly. Lighthouse CI’s GitHub integration makes score deltas visible in the PR comment thread. Bundle size failures should show the diff, not just the total.

Review field data in sprint retrospectives. A 5-minute weekly review of p75 LCP and INP trends surfaces slow regressions that CI never catches because they happen across multiple small PRs, each of which passes individually.

Treat a performance regression the same as a test failure. If the build is red, the PR does not merge. No exceptions for “just this once.”

The teams that maintain good performance over time are not the ones with the most sophisticated tooling. They are the ones that made regressions impossible to ignore.

The Budget Is a Feedback Mechanism

Web performance budgets work because they shift the question from “is this fast?” to “did this get slower?” The first question is vague and subjective. The second is precise and answerable in a CI log.

Set your budgets close to current reality, automate the enforcement, connect synthetic results to field data, and tighten the numbers incrementally. That compound improvement over 12 months outperforms any single optimization sprint.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.