A client came to me with a Lighthouse score of 92 and a "Core Web Vitals: Failed" notice in Search Console. Both were true. Lighthouse ran on a fast machine with an empty cart; their real users were on mid-range Android phones with a cookie banner, a chat widget and a 400 ms interaction delay when opening the menu. Fixing Core Web Vitals in 2026 means diagnosing from field data and knowing which of the three metrics is actually failing and why. Here is my process.
The three metrics, and what "good" means
| Metric | Measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP Largest Contentful Paint | Time until the biggest visible element renders | ≤ 2.5 s | 2.5–4 s | > 4 s |
| INP Interaction to Next Paint | Worst-case (near) latency from a tap/click/key to the next frame, across the whole visit | ≤ 200 ms | 200–500 ms | > 500 ms |
| CLS Cumulative Layout Shift | How much visible content moves unexpectedly | ≤ 0.1 | 0.1–0.25 | > 0.25 |
Google grades the 75th percentile of real Chrome users over 28 days, per URL group. That is why lab scores mislead: your fastest 50% of users can be perfect while the metric fails.
Step 1: Get the field data
# CrUX API: the same data Search Console uses, per origin or per URL
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://shop.example.com/product/sample/","formFactor":"PHONE"}' | jq '.record.metrics | to_entries[] | {(.key): .value.percentiles.p75}'
# → {"largest_contentful_paint": 3100}, {"interaction_to_next_paint": 410}, {"cumulative_layout_shift": "0.04"}
That output already tells the story for this client: CLS is fine, LCP is borderline, INP is failing. Then add your own RUM so you can see which interactions and which elements:
// rum.js -- ~1 KB, loaded with type="module"; sends attribution so you can act on it
import { onLCP, onINP, onCLS } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js';
const send = (metric) => navigator.sendBeacon('/api/rum', JSON.stringify({
name: metric.name, value: metric.value, rating: metric.rating, id: metric.id,
url: location.pathname, ua: navigator.userAgent.slice(0, 80),
attr: {
element: metric.attribution.element ?? metric.attribution.interactionTarget ?? metric.attribution.largestShiftTarget,
type: metric.attribution.interactionType,
inputDelay: metric.attribution.inputDelay,
processing: metric.attribution.processingDuration,
presentation: metric.attribution.presentationDelay,
lcpResourceLoadDelay: metric.attribution.resourceLoadDelay,
lcpResourceLoadDuration: metric.attribution.resourceLoadDuration,
lcpRenderDelay: metric.attribution.elementRenderDelay,
ttfb: metric.attribution.timeToFirstByte,
},
}));
onLCP(send); onINP(send); onCLS(send);
Group by attr.element and you will usually find that one or two elements account for most of the bad INP or LCP. For the client, 70% of poor INP came from the mobile menu button.
Step 2: Fixing INP
INP has three parts: input delay (the main thread was busy when the user tapped), processing (your event handler's work) and presentation delay (rendering the next frame). The RUM attribution tells you which one is large.
Find the long task with the Long Animation Frames API
// In the console or RUM: which scripts are producing frames over 100 ms?
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.duration < 100) continue;
console.table(frame.scripts.map(s => ({
src: s.sourceURL || s.invoker, fn: s.sourceFunctionName, dur: Math.round(s.duration), blocking: Math.round(frame.blockingDuration),
})));
}
}).observe({ type: 'long-animation-frame', buffered: true });
On the client site this pointed at three culprits: a chat widget's initialisation running on first interaction, a menu handler that toggled classes on 400 nodes synchronously, and a third-party A/B script doing a layout read in a loop.
The fixes that work
// 1. Yield to the browser between chunks of work so the frame can paint (Chrome 129+; polyfill with setTimeout)
async function openMenu() {
menu.classList.add('is-open'); // the part the user needs to SEE, first
await scheduler.yield(); // let the browser paint the open menu
buildSubmenus(); // the expensive part, after the paint
await scheduler.yield();
prefetchMenuLinks();
}
// 2. Never read layout then write in a loop (forced synchronous layout)
// BAD: for (el of items) { const h = el.offsetHeight; el.style.height = h * 2 + 'px'; }
const heights = items.map(el => el.offsetHeight); // read all
items.forEach((el, i) => { el.style.height = heights[i] * 2 + 'px'; }); // then write all
// 3. Move CPU work off the main thread
const worker = new Worker('/js/search-index.worker.js');
worker.postMessage({ query }); // filtering 5k products happens here, not on the UI thread
// 4. Defer third-party scripts until after first interaction or idle, and load chat widgets on demand
window.addEventListener('pointerdown', () => import('/js/chat-widget.js'), { once: true, passive: true });
requestIdleCallback(() => loadAnalytics(), { timeout: 5000 });
Also check for content-visibility: auto on long below-fold sections (cuts rendering work), and remove will-change sprinkled everywhere (it creates layers that cost memory and paint time). For React apps, the biggest INP wins are usually from moving components to the server so less JavaScript hydrates, and from useTransition for non-urgent state updates.
Client result: INP p75 from 410 ms to 140 ms. The chat widget lazy-load alone was worth 180 ms.
Step 3: Fixing LCP
LCP is a chain and you fix the earliest broken link. The attribution fields map directly:
<!-- The LCP image: in the HTML, discoverable immediately, high priority, never lazy -->
<img src="/img/hero-800.avif"
srcset="/img/hero-480.avif 480w, /img/hero-800.avif 800w, /img/hero-1200.avif 1200w"
sizes="(max-width: 640px) 100vw, 800px"
width="800" height="500"
fetchpriority="high"
decoding="async"
alt="Summer collection">
<!-- If the hero is a CSS background (avoid if possible), preload it so discovery isn't delayed by CSS parsing -->
<link rel="preload" as="image" href="/img/hero-800.avif" imagesrcset="…" imagesizes="…" fetchpriority="high">
<!-- Everything below the fold: lazy -->
<img src="…" loading="lazy" decoding="async" width="400" height="300" alt="…">
Typical mistakes I find on audits: the hero is loading="lazy" (WordPress adds it by default to every image; filter it off for the first one), the hero is a slider that injects the image with JavaScript, or the image is a 2 MB JPEG served to a 390 px-wide phone. AVIF at the right size is usually 5-10x smaller.
TTFB is the server's job: the FastCGI cache and edge cache take it from 600 ms to under 100 ms for anonymous users, and that improvement flows straight into LCP. Render delay is usually render-blocking CSS: inline the critical CSS for above-the-fold content and load the rest asynchronously; with Tailwind the whole stylesheet is often small enough to inline entirely.
Client result: LCP p75 from 3.1 s to 1.7 s (TTFB −450 ms from caching, image format −600 ms, removing loading="lazy" from the hero −300 ms).
Step 4: Fixing CLS
CLS was fine on the client site, but for completeness, the causes I see and their fixes:
| Cause | Fix |
|---|---|
| Images/iframes/ads without dimensions | Always set width and height (or aspect-ratio in CSS); reserve ad slots with min-height |
| Cookie banners, notification bars injected at top | Position them fixed or reserve the space server-side |
| Web fonts swapping (FOUT) with different metrics | font-display: optional for body text, or size-adjust/ascent-override on the fallback font to match metrics |
| Content loaded via fetch and inserted above existing content | Insert below the viewport or into pre-sized containers; use skeletons with fixed heights |
Animations using top/height | Animate transform and opacity only |
/* Metric-matched fallback so text doesn't jump when the web font arrives */
@font-face {
font-family: "Google Sans Flex Fallback";
src: local("Arial");
size-adjust: 104%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body { font-family: "Google Sans Flex", "Google Sans Flex Fallback", sans-serif; }
Step 5: Stop it regressing
Every fix above will be undone by the next plugin, widget or marketing script unless something in CI says no.
# .github/workflows/perf.yml -- Lighthouse CI against a preview URL on every PR
- uses: treosh/lighthouse-ci-action@v12
with:
urls: |
${{ steps.preview.outputs.url }}/
${{ steps.preview.outputs.url }}/product/sample/
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
// lighthouse-budget.json
[{
"path": "/*",
"timings": [{ "metric": "largest-contentful-paint", "budget": 2000 }, { "metric": "total-blocking-time", "budget": 200 }],
"resourceSizes": [{ "resourceType": "script", "budget": 180 }, { "resourceType": "image", "budget": 400 }, { "resourceType": "total", "budget": 900 }],
"resourceCounts": [{ "resourceType": "third-party", "budget": 8 }]
}]
Lab TBT is a reasonable proxy for field INP in CI, and the third-party count budget is the one that catches marketing additions. Pair it with the RUM dashboard so you can see field metrics move within a week of each deploy.
- Pull CrUX for the failing URL group; identify which metric fails.
- Add RUM with attribution; find the specific element or interaction.
- Fix the earliest link in that metric's chain. Measure. Repeat.
- Put a budget in CI before you move on.
Core Web Vitals are a ranking factor, but the real reason to care is that the 75th-percentile user is the one deciding whether to buy. The client's conversion rate on mobile rose 11% in the month after these fixes, which paid for the engagement several times over. If your Search Console is showing red and Lighthouse is showing green, that gap is exactly the kind of problem I like solving.