A practical Core Web Vitals checklist for small product teams

Technology · 2026-07-12 · 8 min read · 1750 words

By ESPYCRUX, Studio

Most Core Web Vitals advice is written for teams with a performance engineer. This is the checklist we actually work through on a small studio's sites — what the three metrics mean, the specific fixes that move each one, and the two mistakes that make good numbers lie.

Core Web Vitals is a short list, which is what makes it useful. Three metrics, three thresholds, one percentile. You can hold the whole thing in your head, which is more than can be said for most performance advice.

The trap is that the fixes are not short, and most guides list every possible one. On a small team you need the handful that actually move the number, in the order worth doing them.

Here is what we work through.

The three metrics, and what they actually measure

MetricMeasuresGoodNeeds workPoor
LCP — Largest Contentful PaintHow quickly the main content appears≤ 2.5s2.5–4.0s> 4.0s
INP — Interaction to Next PaintHow quickly the page responds when touched≤ 200ms200–500ms> 500ms
CLS — Cumulative Layout ShiftHow much the layout moves unexpectedly≤ 0.10.1–0.25> 0.25

Two things about how these are judged, both of which people get wrong:

You are scored at the 75th percentile, not the average. If three quarters of your visits are fast and a quarter are slow, you are measured by the slow quarter's boundary. An average hides exactly the visits that fail you.

Mobile and desktop are segmented separately. Passing on desktop tells you very little. The score that usually matters is mobile, on a mid-range phone, on a real network.

INP replaced First Input Delay as the responsiveness metric in 2024. If you are working from an older guide that talks about FID, it is out of date — and the difference is meaningful. FID measured only the delay before the first interaction was processed. INP measures the full latency of interactions across the whole visit, including the paint. It is a much harder metric to pass with a heavy JavaScript app, and that is the point.

LCP — get the main thing on screen

LCP is almost always one of: a hero image, a heading, or a large block of text. Find out which before optimising anything — Chrome DevTools' Performance panel labels the LCP element directly.

1. Stop it being discovered late. The single most common LCP failure is an image the browser cannot find until it has parsed and executed something else. If your hero image is set by JavaScript, or lives inside a component that hydrates, the browser learns about it far too late.

Put it in the initial HTML and mark it:

<img src="/hero.avif" alt="…" fetchpriority="high" decoding="async"
     width="1344" height="768">

fetchpriority="high" tells the browser this is the one that matters. Everything below the fold should be loading="lazy" — and your LCP image should never be, which is a surprisingly common mistake when a team adds lazy loading globally.

2. Preconnect to whatever serves it. If fonts or images come from another origin, the browser must do DNS, TCP and TLS before it can even ask:

<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Two or three of these help. A dozen hurt — each one costs a connection.

3. Serve a modern format at the right size. AVIF or WebP, and not a 2400px image scaled down in CSS to 600px. This is dull and it is usually the biggest single win.

4. Do not let a font block the text. If your LCP element is text, a web font with default loading behaviour will hide it while the font downloads. font-display: swap renders immediately in a fallback and swaps when ready. If you use Google Fonts, add &display=swap to the URL — which their generated tag does not always include.

5. Watch what your framework does before first paint. A client-rendered app cannot paint meaningful content until its bundle has downloaded, parsed and executed. If LCP is your worst metric and you are shipping a single-page app, this is usually why, and no amount of image tuning will fix it. Prerendering or server rendering is the actual answer.

INP — the one that catches modern apps

INP is where component-heavy applications get punished, because it measures the thing those apps are worst at: doing a lot of work in response to a tap.

1. Find long tasks. Anything blocking the main thread for more than 50ms delays every interaction that arrives during it. In DevTools' Performance panel they are the blocks flagged with a red corner. You are looking for the ones that run in response to input.

2. Stop re-rendering on high-frequency events. This is the most common self-inflicted INP problem we see, and we had it ourselves. A card that tilts toward the cursor was calling setState on every mousemove — re-rendering the card and its children dozens of times a second and allocating a new state object each time.

The fix is not to throttle it. It is to stop involving the framework at all:

function onPointerMove(e) {
  const el = e.currentTarget;
  const r = el._rect || (el._rect = el.getBoundingClientRect());
  el.style.setProperty('--rx', `${((e.clientY - r.top) / r.height - .5) * -9}deg`);
  el.style.setProperty('--ry', `${((e.clientX - r.left) / r.width  - .5) *  9}deg`);
}

Two custom properties written straight onto the node. No state, no re-render, and the rect is cached on pointerenter rather than read on every event — because getBoundingClientRect() in a move handler forces layout on every single one.

3. Get scroll work off the main thread entirely. A scroll listener that toggles a class re-renders your tree on every scroll event. Most of what those listeners do can now be expressed as a CSS scroll-driven animation, which runs on the compositor and executes no JavaScript. Progress bars, parallax and reveals are all better done that way.

4. Yield before doing heavy work. If a click genuinely must do something expensive, let the browser paint the response first:

button.addEventListener('click', async () => {
  showPending();                                   // immediate visual feedback
  await new Promise(r => setTimeout(r, 0));        // yield so it can paint
  doExpensiveThing();
});

INP measures interaction to next paint. Painting something quickly, then working, scores far better than working and then painting — and it feels better too.

5. Audit third-party scripts. Analytics, chat widgets, ad tags and A/B testing tools all run on your main thread. A single badly behaved tag can dominate your INP, and you will not find it by reading your own code. Load what you can with async or defer, and be ruthless about what earns its place.

CLS — reserve the space

CLS is the easiest to fix and the easiest to leave broken, because it does not show up when you develop on a fast connection with everything cached.

1. Dimensions on every image and video. Always:

<img src="/thing.avif" alt="…" width="800" height="600">

The attributes give the browser an aspect ratio to reserve before the file arrives. CSS can still size it responsively — width: 100%; height: auto — but the ratio prevents the reflow.

2. Reserve space for anything that arrives late. Embeds, ad slots, cookie banners, anything loaded conditionally. If a container will be 250px tall once filled, make it 250px tall now. An element that appears and pushes the article down is the single most irritating layout shift there is.

A related point, learned the hard way: do not ship dummy placeholder boxes as content. Reserving space is a layout technique, not a design element — a page covered in dashed boxes reading "Advertisement" reads as a template built to host ads, whatever it does for CLS.

3. Match your font metrics. A fallback font with different metrics causes a visible jump when the web font swaps. size-adjust and ascent-override on an @font-face let you tune the fallback so the swap is close to invisible.

4. Never insert above existing content. A banner injected at the top of the page pushes everything down and is guaranteed CLS. If you must have one, reserve its space in the initial layout or overlay it.

The two mistakes that make good numbers lie

Testing on your own machine. A developer laptop on office broadband with a warm cache is not your 75th percentile visitor. Test throttled — DevTools' "Slow 4G" and 4× CPU slowdown is a reasonable proxy for a mid-range Android phone. Numbers will get much worse, and those are the real numbers.

Trusting lab data alone. Lighthouse is a lab tool: one synthetic load, in a controlled environment. It cannot measure INP properly, because INP depends on real people interacting in ways a synthetic run does not reproduce.

For field data, use the Chrome UX Report — that is the same source Google uses. If your site does not get enough traffic to appear in CrUX, collect it yourself with the web-vitals library and send it somewhere you can read it. A small site with no field data is flying blind, and lab scores will flatter you.

The checklist

Work down it. Most sites fail on three or four of these, not twenty.

LCP

INP

CLS

Measurement

What we would do first

If you have limited time, in this order:

  1. Add width and height to every image. Fastest CLS fix in existence, usually an afternoon.
  2. Fix your LCP image's priority. One attribute, frequently worth a second or more.
  3. Find the one handler doing too much. There is almost always exactly one — a mousemove, a scroll listener, a search input filtering on every keystroke.

Then measure again, throttled, and only then start on the long tail. Performance work has sharply diminishing returns, and a small team's time is better spent shipping the next thing than chasing a score from 92 to 96.

Thresholds and percentile per web.dev — Web Vitals. INP replaced FID as a Core Web Vital in 2024.

Tags: performance, core web vitals, inp

ESPYCRUX — ESPYCRUX is a small product studio based in India, building focused web applications and writing about the engineering behind them. Articles are written by whoever did the work, and published under the studio name. Reach the studio at admin@espycrux.com.