“It should be fast” is not a budget. We have watched this play out on a
lot of projects. Kickoff includes a slide that says “performance is
critical.” Three sprints later the page renders in 5 seconds on a
mid-range Android, and nobody is sure where the time went. The marketing
team adds three new tracking scripts. The CMS pumps out 4 MB hero
images. The product manager asks for an animated carousel above the
fold. Each decision in isolation is small. Together they ship a slow
site.
The fix is a budget. A specific, written-down, measurable budget that
exists at kickoff and gets checked on every deploy. Not “fast.” Not
“great Lighthouse score.” A list of numbers a feature has to fit inside,
or it does not ship.
Four numbers. They go on the kickoff doc. The client signs off. The
engineering team owns hitting them.
2. Web Fonts Without
A self-hosted font file is 80 KB. The browser delays text rendering
until the font loads. LCP suffers because the hero headline cannot
paint. The fix is one CSS line in every
Why “Fast Enough” Is Not a Budget
“Fast enough” is a feeling. Budgets are numbers. The difference matters because feelings shift over time. The first version of the site loads in 1.8 seconds. Everyone agrees that is fast. Six months later the same site loads in 4.2 seconds. Each new feature added 100 ms. No single sprint introduced the slow site. The drift did. A budget catches the drift on the deploy that introduces it. If the LCP target is 2.5 seconds and a PR pushes it to 2.7, the PR does not ship. The conversation happens that day, not three months later when the slow site is the new normal.Aspirational performance is a marketing line. Budgeted performance is engineering practice.
The Four Numbers We Publish Before Kickoff
These numbers are aligned with Google’s “Good” Core Web Vitals thresholds — what real users feel and what Google ranks against. We hold all four at the 75th percentile of real users.Core Performance Budget
- LCP ≤ 2.5 seconds — Largest Contentful Paint, p75 real users
- INP ≤ 200 ms — Interaction to Next Paint, p75 real users
- CLS ≤ 0.10 — Cumulative Layout Shift, p75 real users
- Total transfer ≤ 1.5 MB on first load (JS bundle ≤ 200 KB gzip)
What We Measure, and What We Ignore
We measure three places: lab data for the developer feedback loop, field data for the truth, and CI gates for the contract.1. Lab Data — Lighthouse in CI
Every PR runs Lighthouse against a preview build. We do not gate on the overall score because Lighthouse score noise is real. We gate on the specific Core Web Vitals metrics with hard thresholds.# .github/workflows/lighthouse.yml (excerpt)
- name: Run Lighthouse CI
run: |
npx lhci autorun \
--collect.url=https://preview-${{ github.event.number }}.example.com \
--collect.numberOfRuns=3 \
--assert.preset=lighthouse:no-pwa \
--assert.assertions.largest-contentful-paint='["error", {"maxNumericValue": 2500}]' \
--assert.assertions.cumulative-layout-shift='["error", {"maxNumericValue": 0.10}]' \
--assert.assertions.total-blocking-time='["error", {"maxNumericValue": 200}]'
Three runs, median value, error if over budget. Total Blocking Time is
the lab proxy for INP because lab cannot simulate real user interactions.
2. Field Data — web-vitals Library
Lab data tells you what your build does on a controlled machine. Field data tells you what your users see on their machines.// src/lib/vitals.js
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
const send = (metric) => {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
attribution: metric.attribution,
page: location.pathname,
});
navigator.sendBeacon('/api/vitals', body);
};
onLCP(send);
onINP(send);
onCLS(send);
Beacon to an endpoint. Endpoint pushes to a time-series store. Dashboard
shows p75 per page over the last 7 days. Alert fires when any metric
exceeds budget for three consecutive deploys. The attribution
import is the underused one — when LCP regresses, attribution tells you
which element took the longest.
3. Chrome User Experience Report
Google publishes p75 LCP, INP, CLS, and FCP for any origin with enough Chrome traffic. The CrUX dashboard atg.co/chromeuxdash is
free. We pull it into our own monitoring once a month for sites that
have CrUX coverage.
What We Cut When the Budget Breaks
A budget that never bends is fiction. A budget that bends without rules is theater. Here are our three rules, in order.What We Measure
- Core Web Vitals metrics directly
- Field data at p75 per page
- Lab data on every PR
- CrUX monthly for real-origin trends
- LCP element via attribution API
What We Ignore
- Lighthouse score as a single number
- Synthetic tests from a single location
- “Eyeball it on my laptop” testing
- Cloudflare auto-optimization proxies
- Optimize-later roadmap items
Three Patterns That Quietly Blow the Budget
The dramatic budget killers are obvious — the 4 MB hero image, the 600 KB bundle, the auto-play carousel. Those get caught in code review. The quiet ones are harder.1. Third-Party Tag Bloat
The marketing team adds Google Analytics. Then Hotjar. Then a chat widget. Then a heatmap tool. Then a “trust seal” script. Then an A/B testing harness. Each script is small in isolation. Together they are a kilobyte concert that runs on every page load. The fix is a third-party budget: a maximum of three third-party scripts on production. Each new request goes through review — what does this script do, and is the answer worth the LCP cost? A recent client wanted six third-party scripts that added 740 ms to LCP on mobile. They kept two. LCP came back under budget.2. Web Fonts Without font-display: swap
A self-hosted font file is 80 KB. The browser delays text rendering
until the font loads. LCP suffers because the hero headline cannot
paint. The fix is one CSS line in every @font-face block.
@font-face {
font-family: 'Onest';
src: url('/fonts/onest-variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
}
swap tells the browser to render the fallback font
immediately and swap when the real font loads. LCP fires on the fallback
render. If the font is critical to brand identity, pair
font-display: swap with
<link rel="preload" as="font"> so the swap happens
sooner.
3. JavaScript Hydration on Static Content
A page that is 90 percent static content is rendered with React, served as JSON, and hydrated in the browser. The user waits for 200 KB of JavaScript to download, parse, and execute before the page is interactive. The fix depends on the framework. Next.js: use static export (output: 'export') for content pages that do not need
runtime React. Astro: islands architecture by default. Plain HTML: ship
plain HTML. We have rebuilt several “React marketing sites” as Astro and
watched LCP drop by a full second with zero visible change to the user.