Skip to main content
Back to Research

Managing Web Performance Budgets in Production

How to set, measure, and enforce performance budgets that actually improve Core Web Vitals — including CI gates, bundle size limits, and TBT reduction…

Abstract

Performance budgets fail when they're aspirational rather than enforced. This article covers setting realistic budgets based on Core Web Vitals thresholds, wiring them into CI so builds fail on violations, reducing TBT with aggressive code splitting, and the LazyMotion pattern for eliminating Framer Motion's bundle overhead.

March 10, 2026
8 min read

Budgets Only Work When They Block

A performance budget that lives in a spreadsheet is not a budget. It's a wish. Performance budgets work when a build fails if they're violated. This means CI integration, automated measurement, and a clear policy on what's a hard failure versus a warning.

The Core Web Vitals thresholds are a reasonable starting point for budget targets:

  • LCP (Largest Contentful Paint): ≤ 2.5s (good), 2.5–4.0s (needs improvement), > 4.0s (poor)
  • INP (Interaction to Next Paint): ≤ 200ms (good), 200–500ms (needs improvement), > 500ms (poor)
  • CLS (Cumulative Layout Shift): ≤ 0.1 (good), 0.1–0.25 (needs improvement), > 0.25 (poor)
  • TBT (Total Blocking Time): not a CWV but Lighthouse's proxy for INP in lab conditions; target ≤ 200ms

Set your budget targets 20% below the "good" threshold in each category. This gives you headroom before a feature addition pushes you into the "needs improvement" zone. The conversation to have with stakeholders is: "we don't want to be at the boundary — we want to be comfortably inside it."

Lighthouse CI Integration

Lighthouse CI (@lhci/cli) runs Lighthouse against your deployed build in CI and asserts against configured thresholds. The configuration file:

// lighthouserc.json
{
  "ci": {
    "collect": {
      "staticDistDir": "./dist",
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:accessibility": ["error", { "minScore": 0.95 }],
        "categories:seo": ["error", { "minScore": 1.0 }],
        "categories:best-practices": ["warn", { "minScore": 0.85 }],
        "first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
        "total-blocking-time": ["warn", { "maxNumericValue": 300 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}

The distinction between "error" and "warn" is deliberate. CLS and accessibility violations block the merge — they're non-negotiable. TBT and FCP are warnings because they're influenced by network conditions in the test environment and can fluctuate.

Run Lighthouse CI as a separate job that depends on the build job completing. Don't run it in the same job as the build because the static file server needs to start up cleanly:

lighthouse:
  needs: build
  runs-on: ubuntu-latest
  steps:
    - uses: actions/download-artifact@v4
      with: { name: dist, path: dist }
    - run: npm install -g @lhci/cli
    - run: lhci autorun

Bundle Size Budgets

Lighthouse scores are lagging indicators. Bundle size budgets are leading indicators — they catch problems before they manifest in Lighthouse. The Next.js bundle analyzer (@next/bundle-analyzer) gives you a treemap of every chunk. Run it before shipping any new feature that adds a dependency:

// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';

const config = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})({
  output: 'export',
  // ...
});

export default config;

Practical size targets for a content site:

  • First-load JS for the landing page: ≤ 150KB gzipped
  • Any single chunk: ≤ 100KB gzipped
  • Total JS for any route: ≤ 400KB gzipped

These numbers are context-dependent. A dashboard application has different constraints than a content site. The principle is the same: set a number, measure against it, fail the build when exceeded.

TBT Reduction

Total Blocking Time measures how long the main thread is blocked by long tasks during page load. Long tasks are JavaScript executions exceeding 50ms. The primary sources in a modern Next.js app:

Library initialization. Three.js, Framer Motion, GSAP, and similar libraries do heavy initialization work on first load. This is inherent to the libraries and not something you can optimize away — but you can defer it.

Parsing large JS bundles. Even if a chunk is async, the browser must parse it when it downloads. A 384KB chunk (raw) is ~1-2ms to parse on a modern laptop, but 8-15ms on a mid-range Android phone. Parse time is proportional to bundle size.

The TBT reduction strategies that actually move the needle:

1. Aggressive lazy loading. Every component that isn't visible in the initial viewport should be lazy loaded. Not some of them — all of them. If a section is below the fold, it does not need to be in the initial bundle.

// Bad: all sections in initial bundle
import HeroSection from './HeroSection';
import SystemsSection from './SystemsSection';
import ContactSection from './ContactSection';

// Good: only hero is synchronous
import HeroSection from './HeroSection';
const SystemsSection = lazy(() => import('./SystemsSection'));
const ContactSection = lazy(() => import('./ContactSection'));

2. LazyMotion for Framer Motion. The full Framer Motion bundle includes all animation features including SVG, layout animations, 3D transforms, and more. LazyMotion with domAnimation strips this to the essentials:

// Before: full bundle (~90KB gzipped)
import { motion } from 'framer-motion';

// After: minimal bundle with dynamic feature loading (~8KB base + ~20KB features loaded async)
import { LazyMotion, domAnimation, m } from 'framer-motion';
const loadFeatures = () => import('framer-motion').then(r => r.domAnimation);

function App() {
  return (
    <LazyMotion features={loadFeatures}>
      <m.div animate={{ opacity: 1 }}>content</m.div>
    </LazyMotion>
  );
}

3. Tree-shaking GSAP. Import only the GSAP plugins you use, not the entire library:

// Bad: entire GSAP ecosystem
import { gsap } from 'gsap';
import 'gsap/all';

// Good: only what you need
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

Measuring What Matters: Field Data vs. Lab Data

Lighthouse is a lab measurement — it simulates a controlled environment. Core Web Vitals in the Google Search Console are field data — measurements from real users on real devices. The gap between them can be large.

Common patterns where lab and field diverge:

  • LCP better in field than lab: Service worker caches resources for returning visitors. Lab tests always start cold.
  • INP worse in field than TBT suggests: Real users interact with the page in ways lab tests don't simulate. TBT is a proxy, not a measurement.
  • CLS worse in field: Third-party scripts (analytics, chat widgets) inject elements that cause layout shifts. Lab tests often don't load third-party scripts.

The CrUX (Chrome User Experience) Report API provides 28-day aggregated field data for any URL with enough traffic. Wire it into your monitoring if you have the traffic volume. For lower-traffic sites, the Google Search Console Core Web Vitals report is the next best option.

The Budget Review Cycle

Performance budgets need a review cycle. Set them quarterly. When a budget is consistently exceeded, you have two choices: optimize to get back under budget, or raise the budget with a documented justification. "The Three.js scene requires it" is a valid justification. "We didn't notice it creeping up" is not.

One practical pattern: track bundle sizes in a JSON file committed to the repository alongside each build. A simple script compares the current build's chunk sizes against the last committed baseline and fails the CI job if any chunk grew by more than 10KB. This catches gradual budget creep that Lighthouse won't flag until it's already degraded performance.

Continue Reading
JCJOOTACEE / OPS

Operational laboratory for AI systems, automation infrastructures, and modular digital ecosystems.

Systems

  • AURA Orchestration
  • MCP Ecosystem
  • Graph Memory
  • AI Agents
  • Docker Infrastructure
  • Industrial Intelligence

System Status

PlatformOperational
APIHealthy
3D EngineActive
MCP Nodes8 Online

Try the Konami code...

Stay in the loop

Occasional updates on AI systems, autonomous infrastructure, and new releases.

© 2026 JootaCee. All systems operational.

RSSChangelogNext.js 16 + React 19 + R3F + GSAP