Learn what LCP, INP and CLS measure, how lab and field data differ, which design choices move each metric, and how to build a real-user monitoring pipeline.
What you'll learn: By the end of this lesson you will be able to explain what Largest Contentful Paint, Interaction to Next Paint and Cumulative Layout Shift measure and how they are judged, tell lab data from field data, name the architecture decisions that move each metric, write a performance budget, measure the metrics in the browser, and design a small real-user monitoring pipeline.
Think of a restaurant. You care how long until the main dish arrives, whether the table wobbles while you eat, and how quickly the waiter reacts when you raise a hand. A kitchen can be proud of its average service time and still lose customers, because customers remember the slow evening.
A web page has the same moments, and the Core Web Vitals put numbers on the ones users feel most. The running example is the product page of an online store: a shopper arrives from search, waits for the main photo, taps "Add to basket", and expects the page to hold still and respond.
The thresholds and API facts below come from web.dev, MDN and the web-vitals README as published in November 2026. Definitions change, so check the linked pages before you put numbers in an alert.
The core idea
Performance work has two halves. Measurement decides what to measure, on whom, and at which percentile. Design is the set of architecture decisions (what the server returns, what the browser fetches first, what runs on the main thread) that move the numbers.
At the time of writing, web.dev lists three Core Web Vitals: Largest Contentful Paint (loading), Interaction to Next Paint (responsiveness) and Cumulative Layout Shift (visual stability). INP became a stable Core Web Vital in 2024 and replaced First Input Delay, which measured only the input delay of the first interaction.
A page passes when it meets the "good" target for all three at the 75th percentile of page loads, segmented across mobile and desktop (web.dev: Web Vitals). A percentile answers "what value did most visits stay within?": at the 75th, three visits in four were at that value or better. It is tolerant of a slow tail, but stricter than an average.
The three metrics
Metric
Question it answers
Good
Needs improvement
Poor
LCP
When did the main content appear?
2.5 s or less
in between
more than 4.0 s
INP
How fast does the page respond when used?
200 ms or less
up to 500 ms
more than 500 ms
CLS
Does the layout stay put?
0.1 or less
in between
more than 0.25
Source: each metric's own web.dev page, linked above.
Largest Contentful Paint (LCP)
LCP reports when the largest visible element in the viewport finishes rendering, timed from the start of navigation. It stands for "the main content has probably loaded"; on the product page, usually the hero photo. Candidates, per web.dev, are images, video posters, elements with a CSS url() background, and block-level text; reporting stops at the first tap, scroll or key press. Because it starts at navigation, LCP includes redirects, DNS, connection setup and server time.
Interaction to Next Paint (INP)
INP observes clicks, taps and key presses (not hover, scroll or zoom) across the visit. For each, it measures the time from the input until the next frame showing a response is painted. The page's INP is its worst interaction, ignoring one outlier per 50 interactions, reported when the user leaves.
An interaction has three phases: input delay (waiting for a busy main thread), processing duration (your event handlers) and presentation delay (style, layout and paint until the frame is on screen).
sequenceDiagram
accTitle: The three phases of one interaction
accDescr: A shopper taps Add to basket. The main thread may be busy first (input delay), then runs handlers (processing duration), then the browser renders the next frame (presentation delay). INP is the time from the tap to that frame.
participant S as Shopper
participant M as Main thread
participant D as Display
S->>M: tap "Add to basket"
Note over M: input delay (busy with another task)
M->>M: run click handlers (processing duration)
M->>D: style, layout, paint (presentation delay)
D-->>S: basket count visibly updates
How to read this: INP is the whole time from tap to updated pixels. A long task already running makes the first phase long even when your handler is tiny.
Cumulative Layout Shift (CLS)
A layout shift is a visible element moving between two frames. Its score is the impact fraction (share of the viewport covered by the moving elements across both frames) times the distance fraction (the biggest move as a share of the viewport's larger dimension). Web.dev's example: 75 percent of the viewport moving 25 percent of its height scores 0.75 x 0.25 = 0.1875.
CLS is not the sum of all shifts. Shifts are grouped into session windows: a shift joins the current window if it comes less than one second after the previous one, and a window lasts at most five seconds. CLS is the worst window. Shifts within 500 ms of a discrete input (click, tap, key press) are excluded.
Supporting metrics for diagnosis
These say where the time went. They are not pass/fail targets.
Metric
What it measures
Core Web Vital?
Lab or field
Time to First Byte (TTFB)
Navigation start to first response byte, including redirects, DNS and TLS. Web.dev's rough guide: 0.8 s or less is good
No
Both
First Contentful Paint (FCP)
Navigation start to first text, image, SVG or non-white canvas. Good is 1.8 s or less
Not listed as one on its web.dev page
Both
Total Blocking Time (TBT)
After FCP, the sum of each long task's time beyond 50 ms
No
Lab. Web.dev says to measure it in the lab; it is a proxy for INP, "not a substitute"
A long task is main-thread work over 50 ms. The browser cannot interrupt it, so input arriving meanwhile waits. Web.dev's example: tasks of 250, 90, 155, 35 and 30 ms block for 200, 40, 105, 0 and 0 ms, so TBT is 345 ms; it suggests aiming under 200 ms on average mobile hardware. TTFB sets the earliest moment anything can render, but web.dev notes a server-rendered page with a higher TTFB can still beat a client-rendered one on FCP and LCP.
Lab data versus field data
Lab data is a controlled run on a fixed device and network, usually in Lighthouse. Field data is real visits.
Lab (Lighthouse)
Field (CrUX, RUM)
Who is measured
One simulated visit
Real visitors
Good for
Debugging, pre-release checks, slow-device tests
What users experience; which pages to fix
Cannot tell you
How real devices, caches and behaviour differ
Why a number is bad; includes only visits that loaded
INP
Cannot predict when users interact (TBT proxies it)
Measured directly
The Chrome User Experience Report (CrUX) is a public dataset of real Chrome users' experience: a rolling 28-day aggregate giving the share of visits rated good, needs improvement or poor, and a 75th percentile. It covers only Chrome, and a page or origin needs enough traffic to appear. Real-user monitoring (RUM) is your own collection of the same metrics from your own visitors, built later in this lesson.
Web.dev's lab and field guide explains why they disagree: the LCP element varies with screen size, personalisation and A/B tests; caches and the back/forward cache (bfcache) speed up real visits; field CLS includes shifts after scrolling. Its advice: prefer field data when you have it, and use lab data to debug and to test slow devices.
What architecture decisions move each metric
Figure 1. Where each metric is decided on one product page load
How to read this: the path from browser to origin decides TTFB and the start of LCP; the browser box is where CLS and INP happen; the muted third-party box is outside your control but inside your metrics.
Figure 2. The hero image on its way to becoming the LCP element
sequenceDiagram
accTitle: Loading the hero image of the product page
accDescr: The browser requests the page from the CDN edge, which fetches HTML from the origin on a miss. After the first byte arrives, the browser parses the HTML, discovers the hero image, requests it, and paints it, which is the LCP candidate.
participant B as Browser
participant C as CDN edge
participant O as Origin
B->>C: GET product page
C->>O: cache miss, fetch HTML
O-->>C: HTML
C-->>B: first byte (TTFB ends)
Note over B: parse HTML, discover hero image
B->>C: GET hero image
C-->>B: image bytes
Note over B: paint hero image (LCP candidate)
How to read this: the four parts of LCP appear in order: waiting for the first byte, the gap before the image request starts, the download, and the time to paint.
Web.dev's LCP optimisation guide splits LCP into exactly those four parts: time to first byte, resource load delay, resource load duration and element render delay. It gives rough proportions (about 40 percent, under 10, about 40, under 10) and warns against turning them into absolute time targets. Use them to find the largest part first.
Figure 3. Reading a bad field LCP
flowchart TD
accTitle: Deciding where to look when field LCP is poor
accDescr: Starting from a poor field LCP, find the largest of the four LCP parts and follow the branch to the matching kind of fix.
A["Field LCP poor at p75"] --> B{"Which LCP part is largest?"}
B -->|"first byte"| C["Server work, caching, CDN, redirects"]
B -->|"load delay"| D["Make image discoverable and prioritised"]
B -->|"load duration"| E["Fewer bytes, right size and format, nearer"]
B -->|"render delay"| F["Render-blocking CSS and JS, long tasks"]
How to read this: choose the branch by measurement, not habit. Shrinking image bytes does nothing if the time goes on waiting for the server.
Resource priority and the critical path
The critical path is the chain of requests the browser must finish before it can paint the main content. Keep it short and make the LCP resource discoverable in the initial HTML: an <img> in the markup is found by the browser's early scan, while an image whose URL only arrives after a JavaScript bundle runs starts late. Web.dev recommends fetchpriority="high" on the likely LCP image (a hint that browsers may override), never lazy-loading it, and keeping high priority for only a few images.
<imgsrc="/img/boot-1200.jpg"srcset="/img/boot-640.jpg 640w, /img/boot-1200.jpg 1200w"sizes="(min-width: 900px) 600px, 100vw"width="1200"height="900"alt="Brown leather boot, side view"fetchpriority="high"><imgsrc="/img/boot-sole-640.jpg"width="640"height="480"alt="Boot sole detail"loading="lazy"><scriptdefersrc="/js/product.js"></script><scriptasyncsrc="https://tags.example.com/loader.js"></script>
The hero has width and height (reserves space), fetchpriority="high" and no loading="lazy". defer downloads in parallel and runs after parsing, in document order; async runs as soon as the script arrives, in no guaranteed order, which suits independent scripts (MDN: script element).
Server response time, caching and CDN
TTFB is the floor under LCP. The decisions are architectural: whether HTML can be served from a cache near the user, how many redirects precede the page, and how much work the origin does before the first byte. Web.dev also notes that unique query-string parameters (analytics ones, say) can make a CDN treat identical content as different, so the cached copy is not used. The browser side is HTTP Caching for the Front End; the server side is caching basics and CDN and object storage. Which rendering strategy produces the HTML changes TTFB and image discovery; see Rendering Strategies.
Image sizing, formats and fonts
Images are often the LCP element and the largest download: serve the size the slot needs (srcset, sizes), a modern format with a fallback, and compress. For fonts, web.dev's font guide says font-display: optional has a short block period and no swap, so a late font is skipped and cannot shift the layout, while swap shows fallback text at once and swaps later. It also notes size-adjust in @font-face can bring a fallback font closer to the web font's size, reducing CLS at the swap.
Layout stability: reserve space
Every CLS fix says "tell the browser how big this will be before it arrives". Web.dev's CLS guide recommends width and height or aspect-ratio for images and video, min-height or aspect-ratio for ad and embed slots, placeholders for late content, and animating transform rather than layout properties.
.gallery { aspect-ratio: 4 / 3; } /* hero space exists before the image loads */.reviews { min-height: 320px; } /* illustrative: reserve the widget's expected size */.promo-slot { min-height: 56px; } /* illustrative: a banner gets a slot, filled or empty */.drawer { transform: translateX(100%); transition: transform 200ms; } /* transform, not left/top */
Main-thread work, long tasks and third parties
The main thread runs your JavaScript and the browser's style, layout and paint work. Long tasks delay input handling (INP's first phase), delay painting the LCP element after its resource has loaded, and raise TBT. Web.dev's INP guide recommends yielding so input can be handled between chunks of work (it names setTimeout), avoiding forced layout, and keeping the DOM small. The Event Loop and the Main Thread explains why a task cannot be interrupted.
Third-party scripts (tags, reviews, chat, A/B tests) share that thread and network. Treat each as a design decision with an owner, a loading strategy, a reserved slot if it renders UI, and a way to switch it off.
Decision
Mainly moves
HTML cached at the edge
LCP (first byte)
Hero image in HTML, high priority
LCP (load delay)
width/height, aspect-ratio, reserved slots
CLS
Code splitting, yielding, less work per click
INP, and LCP render delay
Third parties loaded late, slot reserved
INP, LCP contention, CLS
font-display: optional or matched fallback
CLS
A performance budget is a design artefact
A performance budget is a set of agreed limits on what affects speed. Web.dev's budget guide names three kinds (quantity-based limits on bytes and counts, milestone timings, rule-based scores) and advises combining quantity limits with user-centric metrics, setting them per page type, and enforcing them in the build and after launch. When a change breaks the budget, you optimise something, remove something, or do not add it.
Every figure in this table is illustrative; set yours from your own field data.
What
Example budget (illustrative)
Enforced by
Metric targets
LCP, INP, CLS "good" at p75, mobile and desktop
RUM dashboard and alerts
Hero image size
150 KB
Build check on image files
JavaScript on the product route
200 KB compressed
Bundle-size check in CI
Third-party requests
5
Review of the tag list
Main-thread work in a lab load
TBT under 200 ms
Lighthouse CI
Quantity budgets warn early in CI; metric budgets are the truth but need field data. Use both.
Measuring in the browser
The browser exposes data through the Performance Timeline: register a PerformanceObserver for an entry type and it calls you as entries are recorded (MDN). Use type with buffered: true to also receive entries recorded before the observer existed; entryTypes cannot be combined with buffered. The relevant types are "largest-contentful-paint", "layout-shift" and "event" (plus "first-input"). Support differs by browser and evolves, so test PerformanceObserver.supportedEntryTypes at runtime; MDN marks LayoutShift as limited availability.
// Minimal LCP observer: the last entry received is the current candidate.if (typeofPerformanceObserver !== "undefined" &&
PerformanceObserver.supportedEntryTypes.includes("largest-contentful-paint")) {
let lcp = null;
newPerformanceObserver((list) => {
const entries = list.getEntries();
lcp = entries[entries.length - 1];
}).observe({ type: "largest-contentful-paint", buffered: true });
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && lcp) {
console.log("LCP (ms):", lcp.startTime, "element:", lcp.element, "url:", lcp.url);
}
});
}
This teaches the API but is incomplete. Web.dev's LCP page lists what a correct version must handle: stopping at the first tap, scroll or key press, bfcache restores (not reported by the API, though LCP should be measured), and prerendered pages (measure from activationStart). CLS and INP are harder: summing layout-shift entries that lack hadRecentInput is not CLS, because CLS uses session windows, and INP needs Event Timing entries grouped by interactionId. Use the web-vitals library (npm install web-vitals), which implements these rules: call onLCP, onINP, onCLS or onTTFB with a callback, as in the pipeline code below. Each callback gets a metric object with name, value, rating ("good", "needs-improvement" or "poor"), delta, id (unique per page view and metric, for de-duplication), entries and navigationType. At the time of writing the README documents version 5; onCLS is Chromium only, while onFCP, onINP, onLCP and onTTFB work in Chromium, Firefox and Safari. A web-vitals/attribution build adds an attribution property that breaks LCP and INP into the parts described above, to find causes.
A small real-user monitoring pipeline
Collecting numbers is easy. The design questions are what to send, how much, where, and how to turn it into a percentile.
Figure 4. A real-user monitoring pipeline for the product page
How to read this: dashed arrows are fire-and-forget. The page never waits for the collector, and the collector never waits for the aggregator.
Collect. Record name, value, rating, id and navigationType, plus the dimensions you will slice by: page template (/product/:slug, not the raw URL), device class, release. Send no personal data.
Sample. Decide once per page view, so a visit reports a coherent set. A low rate saves cost; a high rate gives rare segments enough data.
Send with navigator.sendBeacon. It queues a small POST that the browser delivers without delaying unload. MDN says the reliable moment is when visibilityState becomes "hidden"; unload is unreliable and defeats the bfcache. Queued data is limited to 64 KiB (MDN).
Aggregate by percentile. Store counts in value buckets (histograms) per template and day, and compute p75 from merged buckets. Never average percentiles.
Alert when p75 passes the "good" threshold, per template and device class.
JSON.stringify gives a string, so the server parses the text body as JSON. The README's batching example flushes on the same event. Check in the field that the final INP and CLS values arrive, and deduplicate by id on the server.
A tiny percentile example with illustrative LCP values in seconds for five visits: 1.5, 1.8, 2.0, 2.6, 3.9. By the nearest-rank method p75 is the value at rank ceil(0.75 x 5) = 4, which is 2.6 s and misses the 2.5 s target; the mean, 2.36 s, looks like a pass. Percentile tools differ in method, so state yours. For histograms and alerting on user-visible symptoms, see observability and SLOs: the Core Web Vitals targets act as front-end service level objectives.
Talking about performance with product teams
Performance is a product decision with a cost, so use the product's terms.
Speak in visits, not milliseconds. "Three in four product-page visits show the main photo within 2.5 seconds" is understood by everyone.
Make trade-offs explicit. A new widget costs bytes, main-thread time and layout space; ask it to fit the budget, or for something else to leave. A promo banner needs a reserved slot; a tracking tag needs an owner and a switch.
Do not promise a ranking or revenue result. Say what you will measure and show the numbers.
In practice: speeding up an online store's product page
NOTE
This is a realistic scenario, not a case history. It is a composite built from common patterns, and every number in it is an illustrative assumption.
The situation
An online store has a React single-page application, owned by a team of eight. Field data shows product-page LCP at p75 of 3.6 s and CLS of 0.18 (both illustrative); INP is good. Lighthouse in CI looks healthy, because the CI machine is fast and the lab run never receives the marketing team's A/B tests.
Starting architecture
Figure 5. Starting architecture: the browser builds the page after the JavaScript arrives
How to read this: the hero image's URL is in the JSON from the API, so the browser cannot request it until the bundle has run and the API has answered.
The HTML is an empty shell, so image discovery is late (a large resource load delay), and the reviews widget is injected with no reserved slot, pushing "Add to basket" down.
Options considered
Option
Helps
Costs and risk
Tune only: compress images, trim the bundle
Load duration
Cheap, but does not fix late discovery
Server-render the product page; cache HTML at the edge
First byte, discovery, render delay
Rendering infrastructure; stale price if invalidation fails
Rebuild as fully static pages
First byte
Large; slow builds; price freshness
The decision
Field
Decision record
Context
Late discovery of the hero image and unreserved widget space dominate field LCP and CLS.
Decision
Server-render the product route with the hero image in the HTML (fetchpriority="high", not lazy). Cache HTML at the edge for a short time; fetch price and stock separately. Reserve space for gallery and reviews. Load tags with async under a budget.
Consequences, good
The hero image is requested at the first parse; layout is stable; field data drives release decisions.
Consequences, bad
More server work and a cache to invalidate; hydration cost to watch against INP.
Revisit when
Field p75 for any of the three is not "good" two weeks after release.
Rollout and verification
Ship the RUM pipeline (Figure 4) first, tagged by template and release, for a baseline.
Serve the server-rendered route to a small share of traffic and compare p75 between groups; watch TTFB.
Add budget checks and a Lighthouse run to CI, and review the LCP attribution parts weekly.
The 2 a.m. failure
At 02:00 an A/B test published through the tag manager injects a promo banner above the product title for some visitors. CI stays green because the lab run never receives the experiment. The RUM alert on CLS p75 for the product template crosses the "good" threshold within hours. First checks: filter by release and device, read the sources of the layout-shift entries to see which element moved, and check the tag manager's change log. Mitigation: use the tag's kill switch, then relaunch with a reserved slot. Follow-up: add budget review to the tag release process and a lab test with experiments enabled.
Architect's notes
Fix the measurement first: metrics, percentile, device segments and page templates shape what you collect.
Make the LCP element a conscious choice per template: what it is, where it is discovered, who owns it.
Reserve space by rule: any late-arriving element gets a slot of known size.
Budget and own third parties, and give each a switch.
Defer micro-tuning until field data says where the time goes.
Common mistakes
Trusting Lighthouse alone. One simulated visit is not your audience.
Reporting averages. They hide the slow tail; use p75 and histograms.
Lazy-loading the hero image, which adds load delay to LCP.
Images without dimensions, so layout shifts when they arrive.
Sending data on unload. Use visibilitychange with sendBeacon.
Treating TBT as INP, or averaging percentiles instead of merging counts.
Key takeaways
Three Core Web Vitals at the time of writing: LCP, INP and CLS; INP replaced FID in 2024.
Judged at p75 of page loads, by device class: good is LCP 2.5 s or less, INP 200 ms or less, CLS 0.1 or less.
LCP has four parts: first byte, load delay, load duration, render delay. Find the largest first.
TTFB, FCP and TBT are diagnostics; TBT is lab, INP needs field data.
Lab finds causes, field shows truth.
Most wins are architectural: discoverable, prioritised hero image; cacheable HTML; reserved space; less main-thread work; owned third parties.
A budget is a decision rule: quantity limits warn early, metric targets are the truth.
Use web-vitals, send on visibilitychange with sendBeacon, aggregate counts into percentiles.
Practice exercises
Exercise 1 — Starter (30 min): In Chrome DevTools, find the LCP element of a page you know and guess which of the four LCP parts is largest. Check with a Lighthouse run.
Exercise 2 — Intermediate (2 hours): Add web-vitals to a small page and log the metrics. Throttle network and CPU, add an image without width and height, and observe LCP and CLS. Fix it and repeat.
Exercise 3 — Intermediate (2 hours): Write a one-page performance budget for a page type you own, marking which lines CI enforces and which need field data, with an owner for each.
Exercise 4 — Advanced (half day): Build the Figure 4 pipeline locally: a page that sends sampled metrics by sendBeacon to a small endpoint that stores histogram buckets, and a script that prints p75 per template.
Lesson 8 of 8 · Frontend System Design Foundations: How the Web Runs
Learn what LCP, INP and CLS measure, how lab and field data differ, which design choices move each metric, and how to build a real-user monitoring pipeline.
In the rest of this lesson
What architecture decisions move each metric
A performance budget is a design artefact
Measuring in the browser
A small real-user monitoring pipeline
Talking about performance with product teams
In practice: speeding up an online store's product page
Enrolling is free. It unlocks every lesson in Frontend System Design Foundations: How the Web Runs, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.