Skip to content
Dazvix
Journal
Performance6 min read

Core Web Vitals as an engineering budget, not an afterthought

Performance work that happens after launch is remediation. Performance work that happens in CI is engineering.

Almost every performance engagement we take on starts the same way. Someone runs Lighthouse the week before launch, sees a number in the forties, and asks what can be done. By then the answer is always expensive, because the decisions that produced that number were made months earlier: the font stack, the hero image, the third-party tag manager, the component that fetches on mount instead of on the server.

The fix is not a better audit. It is moving the measurement earlier, and giving it teeth. We treat Core Web Vitals as a budget — a number the build is not allowed to exceed — in exactly the way a team treats a failing unit test.

What the three metrics actually measure

It is worth being precise, because a lot of performance advice optimises the wrong thing.

  • LCP (Largest Contentful Paint) — when the largest element in the viewport finishes rendering. Good is ≤ 2.5s. This is almost always an image, a heading, or a block of text, and it is dominated by how fast the server responds and whether the resource was discoverable in the initial HTML.
  • INP (Interaction to Next Paint) — the latency of interactions across the whole page visit, reported near the worst one. Good is ≤ 200ms. INP replaced FID as a Core Web Vital in March 2024, and it is far harder to pass, because FID only measured input delay while INP measures the whole interaction through to the next frame.
  • CLS (Cumulative Layout Shift) — how much visible content moves around without a user action. Good is ≤ 0.1. This is images without dimensions, injected banners, and web fonts that reflow text.

Budgets belong in CI, not in a report

A performance report gets read once and filed. A failing pipeline gets fixed that afternoon. So the first thing we add to a project is a Lighthouse CI step that runs against a production build on every pull request, with assertions that fail the job rather than warn.

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: [
        'http://localhost:3000/',
        'http://localhost:3000/work',
        'http://localhost:3000/services/wordpress',
      ],
      // Median of 3 runs — a single run is too noisy to gate a merge on.
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift':  ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time':      ['error', { maxNumericValue: 200 }],

        // Weight budgets stop regressions before they become timing problems.
        'resource-summary:script:size':     ['error', { maxNumericValue: 170000 }],
        'resource-summary:image:size':      ['error', { maxNumericValue: 400000 }],
        'resource-summary:third-party:count': ['warn', { maxNumericValue: 5 }],
      },
    },
  },
};

Two details matter here. The first is numberOfRuns — CI runners are noisy neighbours, and a single Lighthouse run will flake often enough that the team learns to ignore the job, which is worse than not having it. The second is that we assert on Total Blocking Time rather than INP, because INP needs a real interaction. TBT is the best lab proxy we have, and a page with low TBT almost always passes INP in the field.

Weight budgets are the ones that hold

Timing budgets tell you something broke. Weight budgets tell you what broke, and they are far more stable across CI environments. In practice the script budget is the one that does the work: a 170KB compressed JavaScript ceiling means nobody can quietly add a date library, a carousel and an analytics SDK in the same sprint without a conversation.

That conversation is the entire point. The budget does not stop the team adding the dependency; it makes the cost visible at the moment of the decision, when removing it is still cheap.

The build-time decisions that decide LCP

Once budgets are in place, most LCP work is not optimisation at all. It is removing round trips between the HTML arriving and the browser discovering what it needs.

  1. 01Render the LCP element on the server. If the hero only exists after hydration, the browser cannot start fetching it until the JavaScript executes. This single change is usually worth more than every image optimisation combined.
  2. 02Give it `fetchpriority="high"` and never lazy-load it. loading="lazy" on a hero image is one of the most common self-inflicted LCP wounds we find.
  3. 03Preconnect to any origin on the critical path, and preload the font files actually used above the fold — not the whole family.
  4. 04Serve fonts with `font-display: swap` and a metric-matched fallback, so text paints immediately and the swap does not shift layout. size-adjust on the @font-face fallback makes this close to invisible.
  5. 05Set explicit `width` and `height` (or `aspect-ratio`) on every image. This is a CLS fix, but it also stops the layout recalculating during the critical render.

INP is a main-thread problem

INP is where most modern React sites fail, and it rarely shows up in a lab score. The cause is almost always one of three things: an event handler doing synchronous work, a state update re-rendering a tree far larger than the part that changed, or a third-party script monopolising the main thread at exactly the wrong moment.

The practical fix is to break up long tasks so the browser can paint between them. Anything over 50ms is a long task; anything over 200ms is an INP failure waiting for a user to find it.

// Yield to the browser so it can paint the visual response
// before the expensive work runs.
async function onFilterChange(value: string) {
  setActiveFilter(value);        // cheap: paints immediately

  await yieldToMain();           // let the browser render the new state

  const results = expensiveFilter(value);  // now do the costly part
  setResults(results);
}

function yieldToMain(): Promise<void> {
  // scheduler.yield() is the purpose-built API; fall back where unsupported.
  if ('scheduler' in globalThis && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

The pattern generalises: paint the acknowledgement first, do the work second. A filter that highlights instantly and populates 80ms later feels fast. One that does both after 300ms of blocking feels broken, even though it finished sooner.

Close the loop with field data

Lab budgets stop regressions. They do not tell you what your actual users experience on a mid-range Android phone on a congested network. For that you need field data, which is a few lines of code with the web-vitals library.

import { onLCP, onINP, onCLS } from 'web-vitals';

function report(metric: { name: string; value: number; rating: string }) {
  // sendBeacon survives the page being unloaded mid-flight.
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}

onLCP(report);
onINP(report);
onCLS(report);

Watch the 75th percentile, segmented by device class and route. Aggregate numbers hide the problem almost every time: a site can sit comfortably inside budget overall while its highest-intent page — the one with the booking form and the third-party widget — quietly fails for every phone user.

What this changes in practice

The shift is small and the effect is not. Performance stops being a phase at the end of a project, owned by whoever has time, and becomes a constraint the design and the architecture are built inside. Nobody argues about whether the carousel is worth it in month six, because the budget already answered that in week two.

A performance budget is not a target you hope to hit. It is a limit the build is not allowed to cross.