<link rel="stylesheet" href="assets/fonts/inter.css"> Ultimate Web Performance Budget: How to Set, Measure &… | Viral Patel Studio Skip to main content

Ultimate Web Performance Budget: How to Set, Measure & Enforce Budgets in Angular 2025

May 28, 2025By Viral Patel

Set, measure, and enforce web performance budgets in your Angular app in 2025, covering bundle size, Core Web Vitals, and Lighthouse CI enforcement.

Performance regressions are silent. A developer adds a dependency, a bundle grows by 80KB, nobody notices until a Lighthouse score drops at the quarterly review. By then, the change is buried under dozens of commits, the developer who added it has moved on to another feature, and the "quick fix" turns into a week-long archaeology project. Performance budgets prevent this by making performance a first-class constraint — if a change would break the budget, the build fails, the PR is flagged, and the team fixes it before it ships. This guide covers every layer of Angular performance budgeting: from angular.json bundle config to Lighthouse CI gates to production monitoring. By the end, you will have a complete, copy-paste budget template and a CI setup that makes performance regression impossible to ship accidentally.

learn about Angular Development

performance

angular development

Cumulative Layout Shift (CLS): The Complete Fix Guide for Angular Apps in 2025


Why Performance Budgets Matter

Without budgets, performance degrades incrementally. Each individual addition seems harmless — "it's only 20KB" — but the cumulative effect is devastating. A 20KB addition every other sprint means your initial bundle doubles in a year. The team that ships the breaking PR didn't intend to harm performance; they just had no signal telling them they were.

Budgets matter for three concrete reasons beyond user experience:

LCP is a Google ranking signal. Since the Core Web Vitals rollout in 2021, Largest Contentful Paint is a direct input to Google's page experience signal. A performance budget on LCP is simultaneously an SEO budget. Letting LCP drift from 1.8s to 3.2s does not just hurt users — it erodes organic rankings.

web vitals

Performance is directly tied to conversion. A Deloitte study found that a 0.1-second improvement in mobile load time correlated with an 8.4% increase in conversions for retail sites. Every 100KB of additional JavaScript adds approximately one second of parse and execution time on a mid-range Android device. Bundle budgets are revenue budgets.

The goal is structural, not reactive. The aim of a performance budget is to make it structurally impossible to accidentally ship a performance regression, the same way type checking makes it structurally difficult to ship a type error. Budgets should be enforced automatically — not by relying on engineers to manually check Lighthouse before every merge.


The Three Layers of Angular Performance Budgets

A complete Angular performance budget strategy has three enforcement layers, each catching a different class of problem at a different point in the development cycle.

Layer 1: Build-Time Bundle Budgets (angular.json)

What it enforces: maximum JavaScript and CSS bundle sizes.

When it catches issues: immediately, during ng build in local development and CI.

How it works: Angular CLI reads budget configuration from angular.json and fails the build — or prints a warning — if any bundle exceeds the defined threshold. This is the fastest feedback loop and the most developer-friendly layer.

Layer 2: Audit-Time Web Vitals Gates (Lighthouse CI)

What it enforces: Core Web Vitals scores and metric thresholds (LCP, CLS, INP, TBT, TTI).

When it catches issues: in CI after the build completes, on every PR before merge.

How it works: Lighthouse CI serves the built application and runs Lighthouse audits against it. If any metric misses its threshold, the CI step fails and the PR cannot merge. This catches issues that bundle size alone cannot catch — a correctly-sized bundle can still have a slow LCP if the hero image is unoptimized.

Layer 3: Production Monitoring (Real User Metrics)

What it enforces: actual user experience in production conditions across real devices and networks.

When it catches issues: after deploy, catching regressions that lab-based Lighthouse tests missed.

How it works: the web-vitals library collects real user measurements (CLS, INP, LCP, FCP, TTFB) and sends them to your analytics pipeline. Production monitoring closes the loop — Lighthouse runs on a single machine on a simulated network; real users are on real networks with real devices and real content.


Layer 1: Angular CLI Bundle Budgets

Configuring angular.json Budgets

Angular CLI has native budget support under configurations.production.budgets in angular.json. This configuration ships with every new Angular project; the defaults are permissive. Tighten them.

// angular.json — under configurations.production
{
  "budgets": [
    {
      "type": "initial",
      "maximumWarning": "250kb",
      "maximumError": "500kb"
    },
    {
      "type": "anyLazyModule",
      "maximumWarning": "50kb",
      "maximumError": "150kb"
    },
    {
      "type": "anyComponentStyle",
      "maximumWarning": "6kb",
      "maximumError": "10kb"
    }
  ]
}

Budget type reference:

  • initial — the JavaScript loaded on the first page load. This is the most critical budget. It directly drives Time to Interactive. Every byte here blocks first render on slow networks.
  • anyLazyModule / anyLazyChunk — each lazy-loaded route chunk. These are downloaded on navigation, so they affect navigation speed rather than initial load. Still important for users navigating deeply into your app.
  • anyComponentStyle — encapsulated component CSS generated by Angular's ViewEncapsulation. Prevents any single component from ballooning with embedded SVGs or duplicated utility classes.
  • anyScript — inline <script> tags injected into the HTML.
  • total — the total combined payload of all assets Angular CLI tracks.

Recommended Starting Budgets by App Type (2025)

These are raw byte values as Angular CLI reports them. Multiply by approximately 0.3 to estimate gzip-compressed size, which is what users actually download.

Marketing site or landing page (mobile-first):

  • Initial: warn 150KB, error 300KB
  • Lazy chunk: warn 30KB, error 80KB
  • Component style: warn 4KB, error 6KB

SaaS dashboard (desktop-first, broadband users):

  • Initial: warn 300KB, error 600KB
  • Lazy chunk: warn 80KB, error 200KB
  • Component style: warn 6KB, error 10KB

B2C mobile application (mixed network conditions):

  • Initial: warn 150KB, error 250KB
  • Lazy chunk: warn 40KB, error 100KB
  • Component style: warn 4KB, error 8KB

Enterprise internal tool (fast internal network, authenticated users):

  • Initial: warn 500KB, error 1MB
  • Lazy chunk: warn 100KB, error 300KB
  • Component style: warn 8KB, error 12KB

Start with the tighter budget for your app type. If the build fails, investigate and fix — do not immediately raise the budget. Raising the budget in response to a violation defeats the purpose of having one.


Finding Bundle Bloat: Source Map Explorer

Before you can enforce a budget, you need to understand what is currently in your bundle. source-map-explorer generates a visual treemap showing every file and dependency with its contribution to total bundle size.

# Build with source maps enabled
ng build --source-map=true

# Analyse the output bundle
npx source-map-explorer dist/my-app/browser/*.js

This opens an interactive treemap in your browser. What to look for:

  • Large third-party libraries: moment.js at 72KB, lodash at 70KB, full chart.js at 200KB. Each of these has a lighter alternative or can be selectively imported.
  • Duplicated packages: two versions of the same dependency pulled in by conflicting peer requirements. Check npm ls <package-name> to confirm.
  • Unexpectedly large Angular code: often icon sets imported wholesale, or components containing large prose strings or Base64-encoded images.
  • CommonJS modules: Angular CLI warns about these in the build output. CommonJS modules cannot be tree-shaken. Look for WARNING in <module> depends on 'commonjs-package' in your build log.

Common bloat fixes:

Before After Savings
moment (72KB) date-fns selective imports (~13KB) ~59KB
import _ from 'lodash' (70KB) import { debounce } from 'lodash-es' ~65KB
Full icon font (Material Icons: ~130KB) SVG icon registry with only used icons ~100KB+
import * as d3 from 'd3' import { select, scaleLinear } from 'd3' variable

For lodash specifically:

// Before — imports entire lodash library
import _ from 'lodash';
const result = _.debounce(fn, 300);

// After — tree-shakeable, only debounce is bundled
import { debounce } from 'lodash-es';
const result = debounce(fn, 300);

Layer 2: Lighthouse CI — Automating Web Vitals Gates

Bundle size budgets catch payload problems. Lighthouse CI catches runtime performance problems — slow LCP from an unoptimized hero image, layout shifts from missing image dimensions, high Total Blocking Time from a large synchronous script. Lighthouse CI runs automatically on every PR and fails the build if thresholds are not met.

Installation

npm install -D @lhci/cli

lighthouserc.js Configuration

Create lighthouserc.js at the project root:

module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npx serve dist/my-app/browser -p 4000',
      url: ['http://localhost:4000', 'http://localhost:4000/dashboard'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'categories:accessibility': ['error', { minScore: 0.9 }],
        'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['warn', { maxNumericValue: 300 }],
        'interactive': ['warn', { maxNumericValue: 3500 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

Configuration notes:

  • numberOfRuns: 3 — Lighthouse results have variance. Running three times and taking the median produces more stable results than a single run.
  • error vs warn — error fails the CI step and blocks the merge. warn prints a warning but allows the merge. Use error for hard limits (LCP, CLS), warn for metrics you are actively improving.
  • temporary-public-storage — uploads results to a Google-hosted server for 7 days and posts a URL to the PR. For long-term storage, run your own LHCI server.
  • The url array — add your most critical routes. At minimum: your homepage and your primary conversion page.

GitHub Actions Integration

# .github/workflows/lighthouse.yml
name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build production
        run: npm run build:prod

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

The LHCI_GITHUB_APP_TOKEN enables Lighthouse CI to post score summaries directly as PR status checks and comments. Install the Lighthouse CI GitHub App to generate the token.

The result: every PR shows a Lighthouse score comparison against the base branch. Engineers see the performance impact of their changes before they merge — the same way they see test results.

For more on Angular SSR and how it interacts with Core Web Vitals, see the Angular SSR Hydration Performance Guide.


Layer 3: Production Performance Monitoring

Lab tests (Lighthouse CI) run on a single machine with a simulated network. They catch most regressions, but they cannot replicate every real-world condition — a CDN misconfiguration, a third-party script timing out, a specific device class struggling with JavaScript execution. Production monitoring with real user metrics (RUM) closes this gap.

Web Vitals Library

Google's web-vitals package measures Core Web Vitals in the user's browser and reports them as they occur:

// analytics.service.ts or main.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

interface VitalMetric {
  name: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  id: string;
}

function sendToAnalytics(metric: VitalMetric): void {
  // Send to your analytics endpoint, GA4, or RUM tool
  fetch('/api/vitals', {
    method: 'POST',
    keepalive: true, // ensures the request completes even if the page unloads
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: metric.name,
      value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
      rating: metric.rating,
      id: metric.id,
      page: window.location.pathname,
    }),
  });
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

The rating field is particularly useful for alerting — web-vitals applies Google's thresholds automatically and classifies each measurement as good, needs-improvement, or poor.

What to Alert on in Production

Production alerts should trigger investigation, not panic. Use the 75th percentile (p75) — the threshold Google uses for the Core Web Vitals assessment — rather than the average, which masks long-tail poor experiences.

  • LCP p75 > 2.5s on any key route → investigate immediately
  • CLS p75 > 0.1 on any key route → investigate; likely a layout shift regression from a recent deploy
  • INP p75 > 200ms on any interaction-heavy page → investigate JavaScript execution cost
  • Initial bundle size increase > 10% vs the previous release → automatic Slack/PagerDuty alert from CI bundle size diff
  • TTFB p75 > 800ms → often a server-side or CDN issue rather than a JavaScript issue

Angular-Specific Bundle Optimizations That Enable Smaller Budgets

Setting a tight budget is only useful if the app can meet it. These Angular-specific optimizations give you the structural changes needed to stay under budget sustainably.

Standalone Components and Tree-Shaking

NgModules can impede tree-shaking — Angular's compiler needs to include everything declared in a module even if only one component uses the module. Standalone components are tree-shaken by default because each component declares exactly what it imports:

// Standalone — only CommonModule is included, not an entire shared NgModule
@Component({
  standalone: true,
  selector: 'app-user-card',
  imports: [CommonModule, MatButtonModule],
  templateUrl: './user-card.component.html',
})
export class UserCardComponent {}

Migrating from NgModule-based architecture to standalone is incremental. Start with leaf components (no other components depend on them) and work upward.

Lazy Load Every Route Except the Default

Every route that is not the default landing route should be lazy-loaded. In Angular 17+, the loadComponent syntax makes this natural for standalone components:

export const routes: Routes = [
  {
    path: '',
    component: HomeComponent, // eagerly loaded — this is the landing page
  },
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./dashboard/dashboard.component').then(
        (m) => m.DashboardComponent
      ),
  },
  {
    path: 'settings',
    loadComponent: () =>
      import('./settings/settings.component').then(
        (m) => m.SettingsComponent
      ),
  },
];

Each lazy-loaded route becomes its own chunk, loaded only when the user navigates to that route. The initial bundle shrinks to only what is needed for the landing page.

@defer for Below-Fold Content

Angular 17's @defer block defers the loading of a component — and all its dependencies — until a trigger condition is met. This is the Angular-native equivalent of lazy loading within a template:

<!-- Heavy chart component only loads when it scrolls into view -->
@defer (on viewport) {
  <app-analytics-chart [data]="chartData" />
} @placeholder {
  <div class="chart-skeleton" style="height: 300px;"></div>
}

<!-- Admin panel only loads on interaction -->
@defer (on interaction) {
  <app-admin-panel />
} @placeholder {
  <button>Load Admin Panel</button>
}

@defer is especially powerful for dashboard pages with multiple heavy widgets. Only the above-fold content loads eagerly; everything else defers until needed.

Angular Material: Import Only What You Use

A common anti-pattern in older Angular Material projects is a shared MaterialModule that imports and re-exports every Material module. This prevents tree-shaking and inflates every bundle that imports MaterialModule:

// Anti-pattern — MaterialModule imports everything
// @NgModule({ imports: [MatButtonModule, MatInputModule, MatTableModule, ...everything] })

// Correct — standalone component imports only what it uses
@Component({
  standalone: true,
  imports: [MatButtonModule, MatInputModule],
  // MatTableModule, MatDialogModule, etc. are NOT included in this component's bundle
})
export class LoginComponent {}

With standalone components, there is no structural temptation to create a catch-all module. Each component's imports array is its complete dependency declaration, and the bundler includes only those dependencies.

For a deeper look at Angular 19's performance capabilities including zoneless mode and advanced defer patterns, see the Angular 19 Zoneless and Defer Blocks Performance Guide.


Performance Budget Template for Angular Teams

Copy this template as a starting point. Adjust the numbers based on your app type and user base.

Build budgets (angular.json):

  • Initial bundle: warn 250KB, error 500KB
  • Lazy chunk: warn 50KB, error 150KB
  • Component style: warn 4KB, error 8KB

Lighthouse CI thresholds (lighthouserc.js):

  • Performance score: error below 0.9
  • Accessibility score: error below 0.9
  • LCP: error above 2500ms
  • CLS: error above 0.1
  • TBT: warn above 300ms
  • TTI: warn above 3500ms
  • FCP: warn above 2000ms

Production monitoring alerts (p75, key routes):

  • LCP p75 > 2.5s on any key route → P2 alert
  • CLS p75 > 0.1 on any key route → P2 alert
  • INP p75 > 200ms on any interaction-heavy page → P3 alert
  • Bundle size regression > 10% vs previous release → P2 alert, blocks deploy

Team agreement:

  • Budget violations in CI block merge (no exceptions without engineering lead sign-off)
  • The correct response to a budget violation is to fix it, not raise the budget
  • Budget thresholds are reviewed quarterly — only tightened, not loosened, unless a documented business reason exists

Performance Budget Review Process

Budgets require maintenance. A budget set once and never reviewed becomes stale — either too loose to catch regressions or too tight to be practical as legitimate features are added. Build a lightweight review process:

Every PR: Lighthouse CI runs automatically. No manual action required. Engineers see the score impact before merging.

Weekly: Check CrUX (Chrome User Experience Report) data for key pages via PageSpeed Insights or the CrUX API. Lab results and field results should stay aligned. A growing gap suggests real users are experiencing conditions your lab tests do not simulate.

Monthly: Run source-map-explorer on a fresh production build to check for creeping dependency growth. Dependencies that cross a size threshold without a corresponding feature benefit should be replaced or removed.

Every major dependency update: Re-run the full budget audit. A major version of Angular, Material, or RxJS can change bundle sizes significantly. Verify budgets still pass after the update.

Quarterly: Review budget thresholds as a team. Tighten thresholds where optimizations have landed. Document any threshold changes and the reasoning behind them.


Common Budget Violations and Root Causes

Understanding the pattern of a violation tells you where to look.

1. Initial bundle suddenly 50–100KB larger. A new npm install added a dependency without a size check. Fix: add bundlesize or size-limit as a pre-commit check, or enforce the angular.json budget in CI so the violation surfaces immediately.

2. LCP jumps 600–1000ms on a route with a new hero image. Image added without NgOptimizedImage and the priority attribute. Fix: add NgOptimizedImage to the component's imports, add the priority attribute to the above-fold image, and ensure the image has explicit width and height attributes.

3. CLS score appears on a new route. A component renders content without a placeholder or skeleton screen — content shifts when data loads. Fix: add a skeleton loading state. Even a fixed-height grey rectangle prevents CLS by reserving space before the real content arrives.

4. Lazy chunk exceeds its budget. A developer added a large feature to an existing lazy module instead of creating a new lazy route. Fix: split the feature into its own lazy route, or if it must share a route, use @defer to defer the heavy component within the route.

5. Component style budget violation. A large SVG or a block of base64-encoded image data was embedded directly in a component's SCSS. Fix: move SVGs to the assets folder and reference them as <img src> or use Angular's MatIconRegistry for icon SVGs. Never embed binary data in CSS.

For a comprehensive look at fixing CLS issues specifically in Angular, see the CLS Fix Angular Guide.


A Budget Is a Promise to Your Users

Performance budgets are not an engineering discipline — they are a user experience commitment. Every millisecond of LCP matters on a slow mobile connection. Every kilobyte of JavaScript matters for a user on a 3G network in a rural area. Every layout shift matters for a user with motor difficulties trying to tap a button that keeps moving.

Set your budgets based on your users' real conditions, not your development machine's conditions. If your analytics show 40% of users on mobile in emerging markets, your budget should reflect 3G conditions, not broadband. Use the WebPageTest network throttling profiles to calibrate.

Enforce budgets in CI so the commitment survives team changes, deadline pressure, and "just this one exception" requests. Every exception is a silent vote to abandon the budget. The CI gate exists precisely to resist that pressure — it doesn't negotiate, it doesn't understand deadlines, and it applies the same standard to every PR.

Your users will never know the performance budget exists. They will just notice that the app feels fast, every time, on every device.