The Browser Contract
Core Web Vitals for Engineers: LCP, INP and CLS
What each metric measures, its “good” limit at the 75th percentile, why INP replaced FID, and the difference between field data and lab data.
On this page
In one line: Core Web Vitals are three numbers that describe the user's experience. They are measured on real users' devices. LCP shows how fast the main content appears. INP shows how fast the page responds when the user does something. CLS shows how much the layout jumps around. A page "passes" when all three are good at the 75th percentile.
Core Web Vitals are a set of three metrics, defined by Google, that measure how real users experience loading, responsiveness and visual stability. A metric is a number that you can measure and compare. A percentile is a position in a sorted list of results: the 75th percentile is the value that 75% of the results are better than. At a senior level, talk about performance with metrics, limits and causes. Do not just say "it feels slow." These three metrics give everyone the same words. They also lead into the debugging guides in the Scenario Debugging series.
The three metrics and their thresholds
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP: Largest Contentful Paint (a paint is the moment something is drawn on screen) | Loading: when the largest visible content element is drawn | ≤ 2.5 s | 2.5 to 4 s | > 4 s |
| INP: Interaction to Next Paint | Response: how long the page takes to show a visible reaction to clicks, taps and key presses | ≤ 200 ms | 200 to 500 ms | > 500 ms |
| CLS: Cumulative Layout Shift | Visual stability: content that moves when you do not expect it | ≤ 0.1 | 0.1 to 0.25 | > 0.25 |
"At the 75th percentile" means this. Collect the metric from real page loads and sort them from best to worst. Then look at the value that 75% of visits are better than (the 75th percentile). A page passes a metric if that value is "good". Mobile and desktop are checked separately. So your slowest quarter of users counts. An average can hide problems.
Why INP replaced FID
FID (First Input Delay) was the older responsiveness metric. It measured only the wait before the browser started to handle the first interaction. It missed slow handlers, later interactions, and the time to show the result. INP looks at all interactions during a visit. It measures from the user's action until the next frame is painted. It reports roughly the worst one (on pages with many interactions, it ignores the single worst outlier for every 50 interactions). INP officially replaced FID as a Core Web Vital on 12 March 2024.
LCP in depth
What counts as the LCP element: the largest of these: an <img>, an <image> inside an <svg>, a <video> poster, an element with a CSS background-image loaded through url(), or a block of text. Usually it is the hero image (the big picture at the top) or the main heading.
LCP is the sum of four parts. If you find which part is big, you know what to fix:
| Part | What is slow | Fixes |
|---|---|---|
| TTFB (time to first byte: how long until the first byte of the HTML response arrives) | The server or network | CDN (a group of servers around the world that serve copies of your files from a place near the user), caching, streaming HTML, faster backend, fewer redirects (Lesson 00) |
| Resource load delay | The browser found the image late (CSS background, added by JS, lazy-loaded hero) | Put the image in the HTML as <img>, preload it (a <link rel="preload"> tag that tells the browser to fetch a file early), add fetchpriority="high" (an attribute that tells the browser this file is more important than others), never lazy-load the LCP image |
| Resource load duration | A big file on a slow connection | Modern formats (AVIF/WebP), the right size with srcset/sizes (attributes that give the browser a list of image sizes so it picks one that fits the screen), compression, CDN |
| Element render delay | The browser had the file but could not paint it (CSS or JS that blocks rendering, or client-side rendering that waits for JS) | Inline critical CSS (put the CSS needed for the first screen straight in the HTML), defer scripts (the defer attribute makes a script wait until the HTML is read), render the content on the server (server-side rendering: the server sends ready HTML so the browser does not wait for JavaScript to build the page), reduce main-thread work |
<!-- A good hero: discoverable early, high priority, correctly sized, never lazy --><img src="/hero-1200.avif" srcset="/hero-600.avif 600w, /hero-1200.avif 1200w, /hero-2000.avif 2000w" sizes="100vw" width="1200" height="630" fetchpriority="high" alt="…"/>In Next.js, next/image is a component that makes images the right size and format and loads them well. For the LCP image, use fetchPriority="high" (with loading="eager"). Next.js 16 deprecated the older priority prop. It now prefers preload, which adds a preload link. Check the image documentation for your version.
Scenario: LCP is poor only on mobile
Common causes:
- A huge image sent to a small screen (no
srcset). - Slow mobile networks, which make the resource load duration long.
- Heavy JavaScript, which delays rendering on weaker CPUs.
- Client-side rendering, where content appears only after a big bundle runs.
- A far-away server.
Compare the four parts in field data to see which one is biggest.
INP in depth
The delay of one interaction has three parts:
- Input delay: the main thread was busy, so the event handler could not start.
- Processing time: your event handlers (and what they start) are running.
- Presentation delay: the browser draws the result after the handlers finish (layout, paint).
| Cause | Fix |
|---|---|
| Long tasks blocking the thread | Break them up, use scheduler.yield() (pauses your code so the browser can handle input), use Web Workers (background threads for heavy work) (Lesson 08) |
| Heavy handlers | Do the least work right away (update the UI) and do the rest later |
| Large re-renders in React | Memoise (memoization means saving the result of a calculation so it is not worked out again; React has useMemo for a value, useCallback for a function and React.memo for a component), split components, virtualise lists (draw only the visible rows), use startTransition (marks an update as not urgent) and useDeferredValue (gives you a delayed copy of a value that can lag behind during a busy render) |
| Large DOM (thousands of nodes) | Virtualise, use content-visibility, simplify |
| Third-party scripts | Defer them, load them after interaction, or remove them |
| Layout thrashing | Batch reads and writes |
The rule is: show a reaction fast. Acknowledge the click with some change in the UI. Do the slow work after the next frame.
CLS in depth
A layout shift happens when a visible element moves between frames and the user did not cause it. The layout shift score for one shift is impact fraction × distance fraction. Impact fraction is how much of the viewport was affected. Distance fraction is how far things moved. You multiply the two. CLS reports the worst "session window" of shifts during the life of the page. A session window is a group of shifts that each happen less than 1 second after the one before, with the whole group lasting at most 5 seconds. Shifts within 500 ms after a user input (such as a click that opens a menu) are not counted.
| Cause | Fix |
|---|---|
| Images, video or iframes without a size | Set width and height attributes or CSS aspect-ratio, so space is reserved |
| Ads, embeds (content from another site, such as a video player) or banners that are added late | Reserve a slot with a fixed size (min-height) |
| Content added above existing content (cookie banners, "related" bars) | Add it below the fold (the part you must scroll to see), or overlay it with position: fixed or transform |
| Web fonts that swap in and change the text size | A web font is a font file that the page downloads. font-display: swap shows a fallback font first and swaps when the file arrives, which can move text. Use a fallback font with a matching size (size-adjust, ascent-override), or font-display: optional (use the web font only if it is ready very fast). Preload key fonts |
| Animations that change layout properties | Animate transform instead |
| Skeletons (grey placeholder boxes) that do not match the final size | Make the skeleton the same size as the real content |
/* Reserve space for an image or video before it loads */.card-img { aspect-ratio: 16 / 9; width: 100%; background: #e5e7eb; }/* A slot for a late-loading ad */.ad-slot { min-height: 250px; }Field data vs lab data
| Field data (RUM) | Lab data | |
|---|---|---|
| Source | Real users on real devices and networks | A controlled test (Lighthouse, WebPageTest) on a simulated device |
| Examples | CrUX (Chrome UX Report: a public dataset of real Chrome user measurements, used by PageSpeed Insights and Search Console), or your own RUM (real user monitoring: measuring real visits with a script) with the web-vitals library (a small Google library that measures the Core Web Vitals) | Lighthouse (a tool that loads a page in a controlled setup and gives scores) in DevTools or CI (automatic checks that run on every code change) |
| Good for | What users really experience. Google's page-experience signals use it | Reproducing and debugging: you can repeat it, it shows a waterfall, and it works before release |
| Weakness | Cannot tell you why. Needs traffic. Comes with a delay | May not match real conditions. Cannot measure INP without interaction (Lighthouse uses TBT as a stand-in) |
Which one matters for search ranking? Field data (CrUX). Google uses it, but it is only one small signal among many. Which one do you use to fix things? Both. Field data tells you if and where there is a problem. Lab data lets you reproduce the problem and check your fix.
Collecting your own field data
import { onLCP, onINP, onCLS } from "web-vitals";function send(metric: { name: string; value: number; id: string; rating: string }) { navigator.sendBeacon("/analytics/vitals", JSON.stringify(metric));}onLCP(send);onINP(send);onCLS(send);navigator.sendBeacon is a browser function that sends a small request in the background. We use it because it still works when the page is being closed. Group the data by route, device type and connection to find the worst cases. Look at the 75th percentile, not the mean.
Related metrics worth knowing
| Metric | Meaning | Note |
|---|---|---|
| TTFB | Time to first byte | Server and network. It is one part of LCP. Good is 0.8 s or less |
| FCP | First Contentful Paint | The first text or image. Good is 1.8 s or less |
| TBT | Total Blocking Time | A lab stand-in for responsiveness. For every long task between FCP and TTI, it counts the time beyond 50 ms, and adds these amounts up |
| TTI | Time to Interactive: when the page loaded and reliably answers input | Removed from Lighthouse scoring in version 10 |
Common mistakes
- Quoting averages instead of the 75th percentile.
- Improving the Lighthouse score while real users (field data) still have a bad experience.
- Lazy-loading the LCP image, or using it as a CSS background.
- Talking about FID in a new interview. The answer is INP.
- Leaving out image sizes, and then wondering why CLS is high.
- Fixing only the desktop when mid-range phones decide the 75th percentile.
- Adding a heavy third-party tag (chat, analytics, A/B testing) without measuring its INP cost.
Interview questions
Why did INP replace FID?
FID measured only the input delay of the first interaction. It ignored the processing time, the presentation delay and every later interaction. INP measures the full delay (input delay + handlers + next paint) across all interactions in a visit. It reports roughly the worst one. This shows better how responsive the page feels the whole time. It became a Core Web Vital on 12 March 2024.
Lab vs field data: which one matters for search ranking?
Field data matters. Specifically, it is the CrUX data at the 75th percentile for real users. Lab tools such as Lighthouse are for finding and reproducing problems. They cannot measure INP directly. I use field data to decide what to fix. I use lab data to find and check how to fix it.
Scenario: LCP is poor only on mobile. What are the likely causes?
The likely causes are these. Images are too big and have no srcset. Slow mobile networks make the load duration longer. A heavy JS bundle or client-side rendering delays the paint on slow CPUs. CSS blocks rendering. The hero image is found late. The origin server is far away, or the cache is cold. I split LCP into TTFB, load delay, load duration and render delay to see which one is biggest.
How do you prevent layout shift from images or ads that load late?
I reserve space. I set width and height or aspect-ratio on media. I give ad and embed slots a fixed min-height. I add dynamic content below existing content, or as an overlay. I use fallback fonts with a matching size. I animate with transform.
What are the thresholds and how are they evaluated?
LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1 are "good". They are checked at the 75th percentile of real page loads, separately for mobile and desktop. A page passes the Core Web Vitals check when all three are good.
What are the four parts of LCP and why do they matter?
They are TTFB, resource load delay, resource load duration and element render delay. When you split LCP into these parts, a vague "it is slow" becomes a clear cause. The cause can be the server, how late the image is found, the file size, or something that blocks rendering. Each one has a different fix.
How would you improve INP in a React app?
I find the slow interaction in the Performance panel, or with web-vitals attribution. I break up long tasks and keep handlers light. I reduce how much re-renders (memoisation, keeping state close to where it is used, virtualisation). I mark updates that are not urgent with startTransition or useDeferredValue. I delay or remove heavy third-party scripts.
Why shouldn't you lazy-load the LCP image?
Lazy loading waits to fetch the image until the browser decides it is near the viewport. This makes LCP later. The hero image should be visible in the first HTML and fetched with high priority. In Next.js, use fetchPriority="high" (older versions used the priority prop).
Scenario: CLS is poor only on article pages. What are the likely causes?
Article templates often have ads and embeds that load late, images without sizes, a cookie banner that pushes content down, and web-font swaps. I use the Layout Shift track to see which elements move. I reserve fixed space for ad slots and images (aspect-ratio, min-height). I show banners as overlays and use fallback fonts with a matching size. Then I check field data by template to confirm the fix.
Key takeaways
- LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1 at the 75th percentile. INP replaced FID in March 2024.
- Split LCP into TTFB, load delay, load duration and render delay. Split INP into input delay, processing and presentation. CLS is almost always about reserved space.
- Field data shows what users feel. Lab data shows why. You need both.
- Measure in production with
web-vitals. Report percentiles by route and device. Test on slowed-down hardware.
What's next
That completes the Browser Contract series. Next, continue with the Scenario Debugging series, which uses these ideas on real "it's slow" questions. Or read Backend in Depth, which covers the other side of the connection.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Metadata, SEO, Images and Fonts · Next.js App Router
- Pagination, Filtering and Sorting · API Design
- Node.js Performance Basics: Event Loop Blocking, Streams, Workers · Backend in Depth
Part of the study guide
Related series