- Published on
Actually Fixing Core Web Vitals — The Order in Which You Bring LCP, INP, and CLS Down
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — Lighthouse Says 98 While Search Console Stays Red
- What LCP, INP, and CLS Each Measure, and Their Thresholds
- Lab Data and Field Data — Which One Is the Truth
- The Order for Bringing LCP Down — From Server Response to Fonts
- Why INP Replaced FID, and Breaking Up Long Tasks
- The Three Usual Suspects Behind CLS and the Exact Fix for Each
- Putting Performance Budgets in CI
- Closing — Fix the 75th Percentile, Not the Score
Introduction — Lighthouse Says 98 While Search Console Stays Red
Run Lighthouse locally and performance scores 98. Deploy, open Search Console a week later, and that same page is still sitting under "URLs needing improvement". The team says "Google data must lag", and a month later the situation is unchanged.
The two numbers measure different things. Lighthouse is the result of one load in a controlled environment; what Search Console shows is a 28-day distribution from real visitors. It is very common for improving the former not to bring the latter along, and knowing where that gap comes from is half the job.
This post identifies that gap and then works through actually bringing the three metrics down, in order of impact.
What LCP, INP, and CLS Each Measure, and Their Thresholds
The three metrics cover different phases of the user experience. LCP is loading, INP is responsiveness, CLS is visual stability.
| Metric | What it measures | Good threshold | Measurable in the lab | Most common cause |
|---|---|---|---|---|
| LCP | When the largest content element was painted | 2.5s or less | Yes | Slow TTFB, a late-discovered hero image |
| INP | From interaction to the next paint | 200ms or less | No (TBT as a proxy) | Long tasks, heavy DOM updates |
| CLS | Cumulative score of unexpected layout shift | 0.1 or less | Partially | Images without size, late-inserted banners |
Two points most articles leave out.
First, these thresholds apply at the 75th percentile, not the average. 75% of page loads must have an LCP of 2.5 seconds or less to count as "good". Even with an average LCP of 2.1 seconds, if 25% exceed 4 seconds you fail. The percentile discussion repeats itself exactly here.
Second, mobile and desktop are evaluated separately. It is common to look only at desktop and conclude that you passed, but if most of your traffic is mobile, the number to improve is the mobile one.
The CLS calculation is also worth one pass. CLS is not a simple sum over the whole page lifetime. Segments where shifts occurred are grouped into session windows of at most 5 seconds with gaps of no more than 1 second between shifts, and the value of the window with the largest sum is used. That is why an infinite scroll page left open for a long time does not grow an unbounded score.
Lab Data and Field Data — Which One Is the Truth
Lighthouse simulates a device and a network and loads once. The cache is empty, third-party scripts are at the mercy of that day's response times, and the user clicks nothing. That is why Lighthouse cannot measure INP. There is no interaction. It shows Total Blocking Time (TBT) as a proxy instead, which correlates but is not the same value.
Field data is the opposite. Old Android devices, 3G stretches, repeat visits with a warm cache, ad blockers, and real clicks are all mixed in. This is what is used for search evaluation and this is what users actually experience.
The conclusion is simple. Lab data is for regression detection, and the basis for judgement is field data. Target the Lighthouse score and you end up doing optimizations that raise the score and leave the user experience untouched.
Collecting your own RUM can start with one snippet.
// web-vitals v4 — the attribution build also tells you "what" was slow
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating, // good | needs-improvement | poor
nav_type: metric.navigationType,
path: location.pathname,
// merge devices together and you can diagnose nothing
device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
conn: navigator.connection?.effectiveType,
attribution: metric.attribution,
})
navigator.sendBeacon('/rum/vitals', body)
}
onLCP(send)
onINP(send)
onCLS(send)
onTTFB(send)
Once collected, always look at the 75th percentile per path and per device. A single overall average tells you nothing about which page to fix.
The Order for Bringing LCP Down — From Server Response to Fonts
Look at LCP as a whole and there is nowhere to start. Split it into four pieces and the answer appears.
- TTFB — until the server sends the first byte
- Resource load delay — from TTFB until the LCP resource request starts
- Resource load duration — until that resource has fully arrived
- Render delay — from fully arrived until actually painted on screen
import { onLCP } from 'web-vitals/attribution'
onLCP(({ value, attribution: a }) => {
console.table({
TTFB: Math.round(a.timeToFirstByte),
'Resource load delay': Math.round(a.resourceLoadDelay),
'Resource load time': Math.round(a.resourceLoadDuration),
'Render delay': Math.round(a.elementRenderDelay),
Total: Math.round(value),
Element: a.target,
})
})
// ┌──────────────────────┬───────┐
// │ TTFB │ 412 │
// │ Resource load delay │ 1180 │ <-- the culprit
// │ Resource load time │ 340 │
// │ Render delay │ 96 │
// │ Total │ 2028 │
// └──────────────────────┴───────┘
The target split is roughly 40 percent each for TTFB and resource load duration, with the two delay items close to zero. In the example above, a load delay of 1,180ms means the browser did not know the hero image existed for over a second. No amount of optimizing the image file reduces that number.
Check TTFB First
curl -s -o /dev/null \
-w 'dns:%{time_namelookup}s tcp:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n' \
https://shop.example.com/products/12345
# dns:0.021s tcp:0.043s tls:0.118s ttfb:0.412s total:0.588s
# Check cache hit status at the same time — a CDN miss is a common cause of slow TTFB
curl -sI https://shop.example.com/products/12345 | grep -iE 'cache|age|server-timing'
# cache-control: public, max-age=0, s-maxage=300
# age: 0
# x-cache: MISS
# server-timing: db;dur=284, render;dur=96
Emit the Server-Timing header from the server and you can see the internal composition of TTFB in your RUM data. Without it there is no way to tell whether a slow TTFB is the network or the backend.
Resource Load Delay Is the Biggest Lever
If the LCP element is an image, the preload scanner has to be able to find it. The following three defeat the scanner.
- A hero image set via a CSS background-image — not discovered until the CSSOM is built.
- An image inserted by JavaScript — not discovered until the bundle executes.
- Lazy loading applied to the hero image — the most common self-inflicted wound. Put lazy on an image inside the viewport and the request itself gets pushed back.
<!-- Hero image: keep it in the markup, raise its priority, and never apply lazy -->
<img
src="/hero-1200.avif"
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
sizes="(max-width: 768px) 100vw, 1200px"
width="1200"
height="675"
fetchpriority="high"
decoding="async"
alt="New summer season arrivals"
/>
<!-- Fallback: if it truly cannot live in the markup, stand in for the scanner with preload -->
<link
rel="preload"
as="image"
href="/hero-1200.avif"
imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
imagesizes="(max-width: 768px) 100vw, 1200px"
fetchpriority="high"
/>
If you use next/image in Next.js, giving the hero the priority attribute handles both the fetchpriority="high" above and turning lazy off. For images further down a list, keep the default lazy behaviour instead.
Reduce Render-Blocking Resources Without Eliminating Them Wholesale
There are two pieces of common bad advice.
"Inline all your CSS." Inlining critical CSS is right, but inlining the entire stylesheet grows the HTML, increases parsing after TTFB, and makes caching impossible. Limit the target to the rules needed for the first screen.
"Just add defer to your JavaScript." defer only postpones execution; the download and parse costs remain. If the LCP element is rendered by JavaScript, defer actually delays LCP. What that case needs is server rendering.
Third-party scripts have to be handled separately. Tag managers, chat widgets, and A/B testing tools generally load before the LCP element and take bandwidth and main thread away from it.
# See which resources took bandwidth before LCP
npx lighthouse https://shop.example.com/ \
--only-audits=largest-contentful-paint-element,render-blocking-resources,third-party-summary \
--output=json --output-path=./lh.json --quiet
node -e "
const r = require('./lh.json');
for (const it of r.audits['third-party-summary'].details.items.slice(0, 5))
console.log(it.entity.padEnd(28), Math.round(it.blockingTime) + 'ms blocking', Math.round(it.transferSize / 1024) + 'KB');
"
# Google Tag Manager 412ms blocking 148KB
# Intercom 288ms blocking 231KB
# Hotjar 134ms blocking 92KB
Fonts
Fonts straddle both LCP and CLS. font-display: swap removes the interval where text is invisible, but the layout jumps by the difference in metrics between the fallback font and the real font. The fix is to match the fallback font's metrics to the real font.
@font-face {
font-family: 'Pretendard';
src: url('/fonts/pretendard-subset.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
/* subset to precomposed Hangul + ASCII only — the full font runs to several MB */
unicode-range: U+0020-007E, U+AC00-D7A3;
}
/* Match the fallback font metrics to the real font to remove the shift at swap time */
@font-face {
font-family: 'Pretendard Fallback';
src:
local('Apple SD Gothic Neo'),
local('Malgun Gothic');
size-adjust: 103%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}
Preload only the font files used on the first screen. Preload every weight and they end up fighting the hero image for bandwidth.
Why INP Replaced FID, and Breaking Up Long Tasks
FID measured only the input delay of the first interaction. That is, the time until the browser began running the event handler. Even if the handler then held the main thread for 400ms, FID did not count it. So most sites passed FID easily, and the sites that passed still stuttered.
INP includes all three segments, and it looks at every interaction over the page lifetime rather than only the first.
- Input delay: time the handler start was pushed back because the main thread was busy
- Processing duration: time the event handler ran
- Presentation delay: from the handler finishing until the next frame is painted
Start by identifying which segment is the problem.
import { onINP } from 'web-vitals/attribution'
onINP(({ value, attribution: a }) => {
console.table({
'input delay': Math.round(a.inputDelay),
'processing duration': Math.round(a.processingDuration),
'presentation delay': Math.round(a.presentationDelay),
total: Math.round(value),
target: a.interactionTarget,
event: a.interactionType,
})
})
// input delay 34 / processing duration 268 / presentation delay 92 / total 394
// target: button.filter-apply
If processing duration is large, break the handler up. There is one thing to understand precisely here. await is not yielding. What await creates is a microtask, and microtasks run before the current task finishes, which gives the browser no opening to paint. To actually yield you have to create a task boundary.
// Bad: one click holds the main thread for 380ms
applyButton.addEventListener('click', () => {
applyFilters(state) // 12ms
rerenderTable(rows) // 260ms
syncToServer(state) // 108ms
})
// Good: paint what is visible first, split the rest into tasks and yield
applyButton.addEventListener('click', async () => {
applyFilters(state)
showPendingState() // run only the feedback the user sees immediately, synchronously
await yieldToMain() // the browser paints here
rerenderTable(rows)
await yieldToMain()
syncToServer(state) // work unrelated to the screen goes last
})
function yieldToMain() {
// scheduler.yield keeps priority after yielding, which beats setTimeout
if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield()
return new Promise((resolve) => setTimeout(resolve, 0))
}
Another frequently mistaken point is requestIdleCallback. It runs during idle time, so putting an update that has to appear on screen in there increases presentation delay directly. Use idle callbacks only for work the user is not waiting on, such as sending analytics events or prefetching.
When presentation delay is large, the DOM is usually too big. Have the browser defer the rendering cost of offscreen areas, or virtualize long lists.
/* Skip layout and paint for offscreen cards */
.product-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px; /* estimated size that stops the scrollbar jumping */
}
Long tasks can be monitored continuously.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < 120) continue
console.warn('long task', Math.round(entry.duration) + 'ms', entry.attribution?.[0]?.name)
}
}).observe({ type: 'longtask', buffered: true })
The Three Usual Suspects Behind CLS and the Exact Fix for Each
The causes of CLS are almost fixed, so handling three things usually brings it below 0.1.
First, images and iframes with no declared size. The browser does not know how much space to leave until the resource arrives. The fix is to specify the width and height attributes. Modern browsers compute aspect-ratio automatically from those two values and hold the space even in responsive layouts. Keep the attributes even when you control the size with CSS.
<!-- The attributes are the real pixel size, the CSS is the display size — you need both -->
<img src="/thumb.avif" width="640" height="360" style="width: 100%; height: auto" alt="Product" />
Second, banners, ads, and cookie notices inserted later. Slot an element above already-painted content and everything below it gets pushed down. There are two fixes. Reserve the space in advance, or take it out of the document flow.
/* Reserve the space in advance — no shift even if the ad never arrives */
.ad-slot {
min-height: 250px;
contain: layout;
}
/* Do not put the cookie banner in the flow, float it as an overlay */
.cookie-banner {
position: fixed;
inset-block-end: 0;
inset-inline: 0;
}
There is a common misunderstanding here. "An accordion opened by a user click also counts toward CLS." It does not. Shifts occurring within 500ms of user input get the hadRecentInput flag and are excluded from the score. If content arriving asynchronously past that window pushes things around, however, that does count.
Third, font swap. Solve it with the size-adjust approach from the previous section. On text-heavy pages it is common for this alone to account for half the CLS.
One more thing: animate with transform. Animate top, left, width, or height and every frame is counted as a layout shift and piles straight onto the score.
You can observe what moved directly.
new PerformanceObserver((list) => {
for (const shift of list.getEntries()) {
if (shift.hadRecentInput) continue // shifts right after user input are excluded
for (const src of shift.sources) {
console.log(shift.value.toFixed(4), src.node, src.previousRect, '→', src.currentRect)
}
}
}).observe({ type: 'layout-shift', buffered: true })
// 0.0821 <img class="hero"> DOMRect(0,180,...) → DOMRect(0,412,...)
Putting Performance Budgets in CI
Improvements measured and fixed by hand come back within two deployments. The only thing that stops regressions is a gate.
There is a common mistake here too. Do not gate on the Lighthouse composite score. The score is a weighted average of several metrics and the weights change between versions. A tool update alone can break CI, or conversely a metric can get worse while another improves and the score holds. Gate on the absolute value of individual metrics.
{
"ci": {
"collect": {
"url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/products/12345"],
"numberOfRuns": 5,
"settings": { "preset": "desktop" }
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"server-response-time": ["error", { "maxNumericValue": 600 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
"resource-summary:image:size": ["warn", { "maxNumericValue": 400000 }],
"unsized-images": "error",
"uses-responsive-images": "warn"
}
},
"upload": { "target": "temporary-public-storage" }
}
}
npx lhci autorun
# ✅ .lighthouseci/ directory writable
# ✅ Configuration file found
# Running Lighthouse 5 time(s) on https://staging.shop.example.com/
# ...
# Checking assertions against 2 URL(s), 5 run(s)
# ✘ largest-contentful-paint failure expected <= 2500, found 2884
# https://staging.shop.example.com/products/12345
# Assertion failed. Exiting with status code 1.
Setting numberOfRuns to 5 matters. A single run has too much variance and the gate breaks at random. Lighthouse CI judges on the median of the runs.
Manage bundle size as a separate budget. It is the variable that most directly affects both LCP and INP.
npx size-limit
# Path: .next/static/chunks/pages/index-*.js
# Size: 164.2 kB with all dependencies, minified and brotlied
# Size limit: 180 kB
#
# Path: .next/static/chunks/pages/products/[id]-*.js
# Size: 221.4 kB with all dependencies, minified and brotlied
# Size limit: 180 kB
# ✘ Size limit has been exceeded by 41.4 kB
Finally, passing the CI gate is not the end. CI only catches regressions. The improvement is complete only when you confirm, over the days after deployment, that the RUM 75th percentile actually came down. It genuinely happens often that you shave 400ms in the lab and field data does not budge, and in those cases the bottleneck is usually something that exists only in real user environments.
Closing — Fix the 75th Percentile, Not the Score
There is one sentence to remember. Lighthouse is the tool that catches regressions, and the thing you have to fix is the 75th percentile of field data.
The order of work follows from that. First attach RUM and look at the 75th percentile per path and per device. Pick the single worst path, and if it is LCP, split it into four pieces and pin down which segment it is; if it is INP, pin down whether it is input delay, processing duration, or presentation delay. Apply only the fix that belongs to that segment. Then gate CI on the absolute value of individual metrics so it does not come back.
The most common single win is still the hero image. There are a great many pages where simply removing the lazy attribute, adding fetchpriority="high", and specifying width and height brings LCP and CLS down at the same time. Start there.
Further reading.