The Quiet Outage: How Performance Budgets Keep Your UX (and Revenue) From Flapping

Most teams don’t notice the “brownouts” that kill conversions. Performance budgets make UX reliable, measurable, and enforceable—without cargo-culting Lighthouse scores.

Performance isn’t a feature, it’s availability for humans. Budgets make it measurable and enforceable.
Back to all posts

The outage you didn’t page for

I’ve watched a checkout funnel bleed 6% revenue over a weekend because a “harmless” marketing tag stalled the main thread on mid-range Android. No red dashboards, no 500s—just an INP spike and users abandoning when taps took 600ms to register. Finance felt it before engineering did.

This is the class of incident performance budgets prevent. Not theoretical Lighthouse worship, but guardrails for consistent UX in the environments your users actually have—Moto G on spotty LTE, low-end iPhones on hotel Wi‑Fi, and corporate laptops running 10 privacy extensions.

What a performance budget actually is

A performance budget is an SLO for user experience. It defines the maximum allowed degradation for key user-facing metrics. Not the average, not your local machine’s score—percentiles by segment.

  • Metrics that matter:

    • LCP (largest contentful paint): users seeing the point of the page.
    • INP (interaction to next paint): the new hotness replacing FID; taps and clicks feel snappy.
    • CLS (cumulative layout shift): no jank, no rage taps.
    • TTFB (time to first byte): back-end and CDN sanity check.
    • TBT (total blocking time): lab proxy for main-thread pressure.
  • Good starting budgets (adjust by business tolerance):

    • Home/landing: p75 LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1
    • PDP/search: p75 LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1
    • Checkout: p95 INP ≤ 200ms, LCP ≤ 2.0s, CLS ≤ 0.1
  • Non-visual limits that correlate with pain:

    • Total JS shipped ≤ 200–300 KB gz (50–80 KB brotli) initial route
    • Long tasks (>50ms) count ≤ 20 and total blocking time ≤ 200ms (lab)
    • Third-party script total ≤ 100 KB brotli and never block rendering

Don’t turn this into a single “Lighthouse must be 95+” commandment. I’ve seen teams hit 99 in lab and still fail users in Sao Paulo on 3G.

Tie budgets to business outcomes

Performance budgets are a business tool. Treat them like availability SLOs with error budgets and tradeoffs.

  • Why execs should care:

    • Faster p75 LCP typically correlates with better SEO and paid acquisition efficiency.
    • Every 100ms of latency has a measurable conversion impact (famously reported by Amazon and Walmart; you’ve likely seen your own A/Bs mirror this).
    • Stable UX lowers support tickets and reduces cart abandonment from jank.
  • Map metrics to KPIs:

    • Landing pages: LCP → bounce rate and SEO.
    • PDP/search: INP → add-to-cart rate.
    • Checkout: INP/CLS → form completion rate and payment declines from duplicate taps.
  • Segment by reality, not hope:

    • Device classes: low-end Android vs. flagship iOS.
    • Regions/CDNs: APAC vs. US-East.
    • Network: simulated 3G/4G and real RUM connection types.

Make it enforceable: CI + RUM

Budgets without enforcement are posters in the break room. You need CI to stop regressions and RUM to police production.

  1. Lab guardrails in CI (block the PR):
    • Lighthouse CI with budgets by page type.
    • Bundle size checks with bundlewatch or size-limit.
{
  "resourceSizes": [
    { "resourceType": "script", "budget": 220000 },
    { "resourceType": "stylesheet", "budget": 60000 }
  ],
  "timings": [
    { "metric": "largest-contentful-paint", "budget": 2500 },
    { "metric": "total-blocking-time", "budget": 200 }
  ]
}
# .github/workflows/perf-budgets.yml
name: perf-budgets
on: [pull_request]
jobs:
  lab:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
      - run: npx lhci autorun --config=./lighthouserc.json
      - run: npx bundlewatch
        env:
          BUNDLEWATCH_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  1. Production truth via RUM:
    • Use Server-Timing and CWV APIs to collect LCP/INP/CLS by segment.
    • Tools that won’t fight you: Datadog RUM, New Relic Browser, SpeedCurve, Akamai mPulse.
// Express example: annotate back-end TTFB slice
app.use((req, res, next) => {
  const start = process.hrtime.bigint();
  res.on('finish', () => {
    const durMs = Number(process.hrtime.bigint() - start) / 1e6;
    res.setHeader('Server-Timing', `app;dur=${durMs.toFixed(0)}`);
  });
  next();
});
  1. GitOps the guardrails:
    • If p75 LCP or p95 INP breach budget for 24h, auto-create a P1 and freeze deploys for impacted surfaces.
    • Canary: gate promotion in ArgoCD/Spinnaker until budgets pass in the canary slice.

Concrete optimizations that move the needle

I’ve seen these win consistently across React/Next.js, Vue, and vanilla stacks. Numbers below are p75 improvements on mid-tier Android over typical CDNs.

  • Kill JS you don’t need (−80–200 KB brotli, −100–300ms TBT):
    • Use route-level code splitting and lazy-load non-critical components.
// React: ship less JS for the initial route
const Reviews = React.lazy(() => import('./Reviews'));
function PDP() {
  return (
    <>
      <Hero />
      <Suspense fallback={null}>
        <Reviews />
      </Suspense>
    </>
  );
}
  • Prioritize the LCP element (−400–1200ms LCP):
<link rel="preload" as="image" href="/hero.avif" imagesrcset="/hero-640.avif 640w, /hero-1280.avif 1280w" imagesizes="100vw" fetchpriority="high" />
<img src="/hero-1280.avif" width="1280" height="720" alt="" fetchpriority="high" />
  • Images: right format, right size (−300–800ms LCP):

    • Generate AVIF/WebP, responsive sizes, and don’t lazy-load the LCP image.
    • In Next.js, use next/image with priority for the hero.
  • Fonts: stop layout shifts and slow paints (−100–300ms, CLS fixes):

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-subset.woff2') format('woff2');
  font-display: swap;
}
  • Third-party scripts on a leash (−100–400ms INP/TBT):
<script src="https://example.com/tag.js" async data-cf-beacon="..." crossorigin="anonymous"></script>
<!-- In Next.js -->
<script src="https://example.com/tag.js" strategy="afterInteractive"></script>
  • Budget them explicitly; load after interaction; remove anything that doesn’t pay rent.

  • Inline critical CSS; defer the rest (−200–500ms LCP):

    • Use critters (Next.js) or penthouse in build.
  • Network basics that still matter (−100–300ms TTFB/LCP):

<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="dns-prefetch" href="//cdn.example.com">
  • Compression/caching (−KB shipped, fewer long tasks):
# Brotli over Gzip
brotli on;
brotli_comp_level 6;
brotli_types text/html text/css application/javascript image/svg+xml;

# Immutable caching for hashed assets
location ~* \.(js|css|woff2)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}
  • Hydration strategy (−100–400ms INP):

    • Avoid useLayoutEffect for non-critical work; prefer requestIdleCallback for analytics.
    • Evaluate React Server Components/Next.js app/ to reduce client JS.
  • Database and edge (−100–400ms TTFB):

    • Move personalized cacheable bits to the edge (KV/Function, e.g., Cloudflare Workers, Fastly Compute@Edge).
    • Cache HTML for anonymous traffic with a signed-in hole-punch.

Real result from a retail client: 2 sprints, p75 LCP from 3.2s → 1.9s and p95 INP from 280ms → 160ms on Android; add-to-cart +7.3%, SEO traffic +5% over 30 days. No heroics—just budget-driven focus.

Design budgets by page and segment

Don’t share one number across everything. You’ll either block shipping or bless mediocrity.

  • Page groups:
    • Landing/marketing, PDP/search, account/checkout, app shell.
  • Segments:
    • Device: low (Android < 6 CPU cores), mid, high.
    • Region: primary vs. long-tail (e.g., LATAM/APAC).
    • Network: 3G/4G, corporate proxies.

Example budget table (abbreviated):

  • Landing, mid-tier Android, US: p75 LCP ≤ 2.3s, INP ≤ 200ms, CLS ≤ 0.1, JS ≤ 250 KB gz.
  • Checkout, all devices, US/EU: p95 INP ≤ 200ms, CLS ≤ 0.1; third-party ≤ 60 KB brotli.

Update quarterly. When marketing insists on a heavyweight tag for a campaign, deduct that from the same surface’s error budget and require a removal date.

Treat it like SRE: error budgets for UX

  • Define a monthly error budget: e.g., p75 LCP may exceed 2.5s for ≤ 5% of sessions.
  • If the budget is spent:
    1. Freeze features on impacted surfaces.
    2. Roll back the hottest regressions (feature flags/Argo rollbacks).
    3. Prioritize fixes that reduce main-thread blocking and critical path bytes.
  • Post-incident: add a test or budget line item (e.g., “third-party total ≤ 100 KB brotli on checkout”).

I’ve seen this change behavior faster than any “performance champions” committee.

A Monday-start plan

  1. Pick 3 pages and set realistic, segment-aware budgets for LCP/INP/CLS.
  2. Add lighthouse-ci and bundlewatch to PRs; fail builds on budget breach.
  3. Turn on RUM (Datadog/New Relic/SpeedCurve) and ship Server-Timing.
  4. Triage: remove one third-party, fix LCP preload, split one chunky bundle.
  5. Write a one-pager policy: error budgets, freeze rules, rollback steps.
  6. Review in two weeks; tighten budgets after you bank the wins.

Related Resources

Key takeaways

  • Performance budgets are UX SLOs, not vanity Lighthouse targets.
  • Define budgets by page type, device class, and region; enforce at p75/p95, not averages.
  • Use CI to block regressions (Lighthouse CI, bundlewatch) and RUM to police production.
  • Optimize what users feel: LCP (content), INP (interactions), CLS (stability), and JavaScript main-thread pressure.
  • Treat exceptions with an error-budget policy that prioritizes fixes over features when UX degrades.

Implementation checklist

  • Pick user-facing metrics: LCP, INP, CLS, TTFB (plus TBT for lab).
  • Segment budgets by page type, device class, and network conditions.
  • Set percentile targets (p75 or p95) tied to business KPIs.
  • Instrument RUM with `Server-Timing` and CWV; add synthetic tests for guardrails.
  • Enforce budgets in CI with `lighthouse-ci` and `bundlewatch`.
  • Make third-party scripts a first-class budget line item.
  • Automate rollback/canary if budgets are breached.
  • Review budgets quarterly; revise when product or traffic mix changes.

Questions we hear from teams

Should budgets target Lighthouse scores or Core Web Vitals?
Target Core Web Vitals (LCP, INP, CLS) at p75/p95 in production RUM. Use Lighthouse/TBT as lab proxies and CI guardrails, not the source of truth.
How strict should budgets be on low-end devices?
Set budgets per device class. Don’t force low-end targets to match iPhone 15 Pro. Define acceptable ranges (e.g., p75 LCP ≤ 3.0s on low-end Android) and improve through JS dieting and image strategy.
What about Single Page Apps and hydration?
Budget INP aggressively. Defer non-critical hydration, split routes/components, and move work server-side (RSC/SSR). Track long tasks and keep TBT ≤ 200ms in lab. Prioritize the LCP element and avoid lazy-loading it.
How do we handle third-party scripts politically?
Make them a first-class budget line item with an owner and an expiry date. Load after interaction or asynchronously, isolate via web workers if possible, and remove if they don’t demonstrably lift a KPI.

Ready to modernize your codebase?

Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.

Stabilize your Core Web Vitals Speak with a principal engineer

Related resources