Skip to content

Performance Budgets

advanced18 min read

What Gets Measured Gets Managed

Every team says they care about performance. But without a budget — a concrete threshold that blocks a deploy when exceeded — performance death is just one PR at a time. Someone adds a date library (72KB). Another PR adds an animation library (45KB). A third adds a rich text editor loaded on every page (200KB). No single PR is the villain. The sum is.

A performance budget turns "we should keep this fast" into "this deploy is blocked until you shave 25KB." It transforms performance from a vague aspiration into an actual engineering constraint.

Mental Model

A performance budget works like a financial budget. You have a total balance (say, 200KB of JavaScript). Every feature costs something. Before adding a new feature, you check: does the budget allow it? If not, you either optimize existing code to make room, or you cut a less valuable feature. Without a budget, you just keep spending until the account is empty — and the user pays the debt as slow load times.

Three Types of Budgets

1. Size Budgets

The simplest and most enforceable — just cap the bytes:

AssetBudgetRationale
Total JavaScript (compressed)< 200KBFast 3G loads ~170KB/s. 200KB = ~1.2s download
Total CSS (compressed)< 50KBRender-blocking — every KB delays first paint
Individual route JS< 80KBKeep per-page cost low for code-split apps
Images (per page)< 500KBLargest contributor to page weight
Web fonts< 100KBBlock text rendering until loaded
HTML document< 30KBFirst byte to first token
// size-limit configuration (.size-limit.json)
[
  {
    "path": ".next/static/chunks/*.js",
    "limit": "200 KB",
    "gzip": true
  },
  {
    "path": ".next/static/css/*.css",
    "limit": "50 KB",
    "gzip": true
  },
  {
    "name": "Home page JS",
    "path": ".next/static/chunks/pages/index-*.js",
    "limit": "80 KB",
    "gzip": true
  }
]

2. Timing Budgets

Set thresholds for how long real interactions take:

MetricBudgetWhy
LCP (Largest Contentful Paint)< 2.5sGoogle's "good" threshold
INP (Interaction to Next Paint)< 200msUser perceives delay above 200ms
CLS (Cumulative Layout Shift)< 0.1Visible layout instability above this
Time to Interactive (TTI)< 3.8sPage usable within 4 seconds
First Contentful Paint (FCP)< 1.8sSomething visible fast

3. Rule-Based Budgets

Structural rules that prevent performance anti-patterns:

  • No synchronous third-party scripts
  • All images must have explicit width and height
  • No CSS @import (blocks rendering in a chain)
  • Fonts must use font-display: swap or optional
  • No uncompressed assets over 10KB
Quiz
A team sets a JavaScript budget of 200KB compressed. A new feature adds 45KB. The current total is 180KB. What should happen?

Setting Budgets from Real User Data

This is crucial: don't guess your budgets. Derive them from your actual users' devices and network conditions.

Step 1: Know your audience

Use analytics to determine the device and network profile of your P75 user (the user at the 75th percentile — worse than average, but not the worst case).

Real example from a SaaS product:
- P75 device: Samsung Galaxy A13 (mid-range, 2021)
- P75 network: 4G with ~8 Mbps effective bandwidth
- P75 CPU: ~3x slower than a MacBook Pro

Your MacBook on gigabit fiber is NOT your user.

Step 2: Work backward from timing goals

If your LCP goal is 2.5s on the P75 device/network:

Available time: 2500ms
- DNS + TCP + TLS: ~300ms (first visit)
- Server response (TTFB): ~400ms
- HTML download + parse: ~200ms
- CSS download + parse (render blocking): ~300ms
- Remaining for JS + render: ~1300ms

At 8 Mbps = 1MB/s:
- 1300ms = ~1.3MB of bandwidth
- But mid-range CPU needs ~3x parse/execute time vs desktop
- So JS budget: ~200KB compressed (700KB parsed, ~1s parse+execute on mid-range)

Step 3: Allocate across routes

Not every route needs the same budget. A marketing landing page should be lighter than a dashboard with charts:

Total JS budget: 200KB compressed

Shared framework (React + Next.js runtime): ~85KB
Shared UI components (design system): ~30KB
Remaining per-route budget: ~85KB

Landing page: 20KB route JS + 85KB shared = 105KB total
Dashboard: 80KB route JS + 85KB shared = 195KB total
Settings: 15KB route JS + 85KB shared = 100KB total
Quiz
Your analytics show the P75 user has a mid-range Android phone on 4G. You've been testing on a MacBook Pro with gigabit wifi. Your LCP is 1.2s in testing. What's the likely real-world LCP?

Enforcing Budgets in CI

A budget that isn't enforced is a suggestion — and suggestions get ignored. Wire budgets into your CI pipeline so they actually block merges.

size-limit

npm install --save-dev size-limit @size-limit/preset-app
// package.json
{
  "size-limit": [
    { "path": ".next/static/**/*.js", "limit": "200 KB" }
  ],
  "scripts": {
    "size": "size-limit",
    "size:check": "size-limit --why"
  }
}
# GitHub Actions
- name: Check bundle size
  run: npx size-limit
  # Exits with code 1 if budget exceeded — blocks the PR

Lighthouse CI

// lighthouserc.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'interactive': ['error', { maxNumericValue: 3800 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-byte-weight': ['error', { maxNumericValue: 500000 }],
      },
    },
    collect: {
      url: ['http://localhost:3000/', 'http://localhost:3000/dashboard'],
      numberOfRuns: 3,
    },
  },
};

bundlesize

// package.json
{
  "bundlesize": [
    { "path": ".next/static/chunks/main-*.js", "maxSize": "80 KB" },
    { "path": ".next/static/chunks/framework-*.js", "maxSize": "50 KB" },
    { "path": ".next/static/css/*.css", "maxSize": "30 KB" }
  ]
}
Budget enforcement strategy

Start with warnings, not hard blocks. When introducing budgets to a team, run them in "warning mode" for 2-4 weeks. This reveals the current baseline and lets the team adjust. Once budgets are calibrated to realistic values (your current P90 is a good starting point), switch to blocking mode. Budget violations should be treated like failing tests — they block the PR until resolved. Over time, ratchet budgets down as the team optimizes.

Quiz
Your CI runs Lighthouse on every PR. Lighthouse scores fluctuate between 85-95 on the same code due to network variability. How do you make budget enforcement reliable?

Monitoring Regression Over Time

Budgets catch regressions at PR time, which is great. But what about the slow creep that no single PR triggers? You also need to track real-user performance over time to detect gradual drift and environmental changes (CDN issues, third-party script slowdowns).

Real User Monitoring (RUM)

// Report Core Web Vitals from real users
import { onLCP, onINP, onCLS } from 'web-vitals';

function reportMetric(metric) {
  // Send to your analytics endpoint
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,  // 'good' | 'needs-improvement' | 'poor'
      page: window.location.pathname,
      connection: navigator.connection?.effectiveType,
      deviceMemory: navigator.deviceMemory,
    }),
    keepalive: true,  // survives page unload
  });
}

onLCP(reportMetric);
onINP(reportMetric);
onCLS(reportMetric);

Track P75 and P90 values over time. Alert when metrics regress by more than 10% from the trailing 7-day average. This catches regressions that slip past CI — third-party script updates, CDN configuration changes, increased page complexity from content additions.

Quiz
Your P75 LCP has slowly increased from 2.0s to 2.8s over 3 months, but no single PR triggered a budget alert. Why?

Budget Allocation Across Routes

Not all pages deserve the same budget. A marketing landing page must load fast to convert visitors. An admin settings page? The user is already committed — you've got more room to breathe.

Priority tiers:
Tier 1 (< 150KB JS): Landing page, pricing page, sign-up
  → First impression pages. Every 100ms delay = 7% conversion loss
  
Tier 2 (< 200KB JS): Dashboard, course viewer, search results
  → Core experience. Users expect responsiveness

Tier 3 (< 300KB JS): Settings, admin panel, analytics
  → Low-traffic, committed users. More complexity acceptable

Tier 4 (no strict budget): Internal tools, dev dashboards
  → Not user-facing. Optimize for developer productivity
The 14KB rule for initial load

The first TCP round trip delivers approximately 14KB (10 TCP packets × ~1.4KB each). If your critical HTML + inlined CSS fits within 14KB, the browser can start rendering after a single round trip. This is why inlining critical CSS and keeping initial HTML small has an outsized impact on FCP.

Key Rules
  1. 1A performance budget is a threshold that blocks deploys when exceeded — not a suggestion, a constraint.
  2. 2Three budget types: size (KB of JS/CSS), timing (LCP/INP/CLS thresholds), and rule-based (structural anti-pattern prevention).
  3. 3Derive budgets from your P75 user's real device and network — not your development machine.
  4. 4Enforce in CI with size-limit (deterministic byte checking) and Lighthouse CI (timing with multiple runs for stability).
  5. 5Allocate budgets per route: landing pages get tight budgets, admin pages get more room.
  6. 6Monitor real-user metrics (P75, P90) over time to catch gradual regression that per-PR checks miss.
  7. 7Ratchet budgets down after optimization sprints — never only up.
Interview Question

Q: Your team has never had performance budgets. The current home page ships 450KB of JavaScript (compressed). How do you introduce budgets without blocking every PR?

A strong answer: Start by measuring the current state — 450KB is the baseline. Set the initial budget at 460KB (slightly above current) so existing PRs aren't blocked. Enable "warning mode" in CI for 2 weeks. During this period, run an optimization sprint: audit dependencies (bundle analysis), remove unused code, lazy-load below-fold components, convert client components to server components where possible. Target getting to 300KB. Once there, set the budget at 320KB (10% headroom). Switch to blocking mode. Ratchet down every quarter. Simultaneously, set timing budgets using Lighthouse CI: LCP < 2.5s, INP < 200ms, CLS < 0.1.