Performance budgets fail when they are aspirational. A budget of "LCP under 2 seconds" means nothing if you do not measure, enforce, and respond.
A real budget has teeth: it gates PRs, alerts on regressions, and provides a playbook to stay within bounds.
Pick Metrics That Move UX
You cannot optimize what you do not measure. Pick three to five metrics that genuinely impact user experience.
Largest Contentful Paint (LCP)
LCP measures when the largest piece of content becomes visible. This could be a hero image, a headline, or a text block.
Why it matters: Users perceive pages based on what they see first. If the hero takes 5 seconds, the page feels slow, no matter what happens after.
Target: < 2.5 seconds (Web Vitals "Good" threshold).
Measure: Real User Monitoring (RUM) and synthetic tests (Lighthouse, WebPageTest).
Interaction to Next Paint (INP)
INP measures the time from user input (click, tap, keystroke) to the next visual feedback.
Why it matters: A fast-loading page that feels sluggy on interaction is worse than a slightly slower page that responds instantly.
Target: < 200 milliseconds (Web Vitals "Good" threshold).
Measure: Real user interactions. Synthetic tests are limited because they cannot predict user interaction timing.
Cumulative Layout Shift (CLS)
CLS measures how much the layout moves around while the user is viewing it.
Why it matters: Ads loading, late images, or fonts blocking layout causes the text to shift under the user's finger. They click on the wrong button. It feels janky.
Target: < 0.1 (Web Vitals "Good" threshold).
Measure: RUM. CLS only happens with real user interaction patterns.
Hydration Time
For server-rendered or streaming apps, track how long it takes from HTML arrival to interactive.
Why it matters: A page that renders fast but hydrates slowly still feels sluggy.
Target: < 1 second for the critical path (buttons, forms).
Measure: Custom instrumentation. React DevTools Profiler or Web Vitals library.
Error Budgets
Track failed interactions, JavaScript errors, and silent failures (requests that error without user feedback).
Why it matters: A performant page that breaks silently is worse than a slow page that works.
Example SLA: < 0.1% of sessions have a JavaScript error that breaks the page.
Gates That Developers Respect
Developers ignore budgets they do not see during development. Add gates to the PR workflow.
PR Checks with Synthetic Budgets
Run Lighthouse, PageSpeed Insights, or custom timing scripts on every PR:
# In CI (e.g., GitHub Actions)
npx lighthouse https://staging.example.com/pr-${{ github.event.number }} \
--output=json \
--output=html \
--chrome-flags="--headless"
Report the results inline in the PR:
❌ Performance Budget Violations
- LCP: 3.2s (budget: 2.5s) — hero image not optimized
- Bundle size: 450KB (budget: 400KB) — new dependency added
- TTI: 5.1s (budget: 4.5s) — heavy script on main thread
Suggestions: Optimize hero image with next/image. Audit new dependencies for size.
Canary RUM with Alerts
Deploy to a staging or canary environment. Collect real user metrics. Alert if key metrics degrade:
// Send to analytics backend
if (new PerformanceObserver.supportedEntryTypes?.includes('largest-contentful-paint')) {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
analytics.send({
metric: 'lcp',
value: lastEntry.renderTime || lastEntry.loadTime,
url: window.location.pathname
});
}).observe({ entryTypes: ['largest-contentful-paint'] });
}
Alert on > 10% regression:
# Alert config
alert:
name: LCP Regression
condition: LCP > baseline * 1.1
action: Notify #frontend Slack, block deployment
Block on Regressions > 5–10%
A rule: PRs that increase bundle size by more than 10% or degrade LCP by more than 10% require a waiver.
The waiver forces a conversation: "Is this feature worth the cost?"
Often, the answer is "no, let's optimize." Sometimes, it's "yes, and here's our plan to offset it elsewhere."
Without the waiver, the rule is arbitrary. With it, it's a tradeoff.
Playbooks: How to Fix Common Failures
A budget with no playbook is just a wall. Pair each metric with a how-to guide.
LCP Playbook
Problem: Hero image takes 5 seconds to render.
Root causes:
- Image not preloaded.
- Image size too large.
- Image served from slow CDN.
- Image has render-blocking CSS or script above it.
Solutions:
- Add
<link rel="preload" as="image" href="..." />in<head>. - Compress and resize: use WebP, AVIF, and responsive srcsets.
- Use
fetchPriority="high"on<img>(fetch before other images). - Serve from a fast CDN (Cloudflare, Fastly, Akamai). Measure TTFB.
- Defer non-critical scripts and CSS until after hero render.
<!-- Good -->
<head>
<link rel="preload" as="image" href="hero.webp" fetchpriority="high" />
</head>
<body>
<img
src="hero.webp"
alt="Hero"
fetchpriority="high"
width="1200"
height="600"
/>
</body>
INP Playbook
Problem: Button clicks take 500ms to show feedback.
Root causes:
- Heavy event listener on the main thread.
- Long script execution during interaction.
- Layout thrashing (read, write, read, write).
- Slow state update batching.
Solutions:
- Defer non-critical work: use
startTransitionin React to de-prioritize expensive updates.function handleClick() { startTransition(() => { // Heavy update: filtering, sorting setFilteredItems(expensiveCompute(items)); }); // Quick update: visual feedback setLoading(false); } - Batch DOM reads and writes: Read all, then write all.
- Use
debounceorthrottlefor frequent events (scroll, resize). - Offload heavy work to a Web Worker or schedule it with
requestIdleCallback.
CLS Playbook
Problem: Text shifts when an image loads.
Root causes:
- Image has no width/height, so browser cannot reserve space.
- Font loads late, causing fallback font to be taller.
- Ad or lazy-loaded content pushes layout.
Solutions:
- Always specify
widthandheighton images. If responsive, useaspect-ratio:<img src="..." alt="..." width="1200" height="600" style="aspect-ratio: 2 / 1" /> - Use
font-display: swapto show fallback immediately:@font-face { font-family: "MyFont"; src: url("myfont.woff2") format("woff2"); font-display: swap; /* Show fallback until loaded */ } - Reserve space for ads or lazy content with a container query or fixed height.
The Mindset
Budgeting is successful when it guides trade-offs, not when it shouts red numbers.
When a feature adds 50KB to the bundle, the team sees it during PR review and decides: "Is this worth it?" If yes, how do we offset it elsewhere? If no, how do we refactor?
That conversation—that is the value of the budget.