FE-04 — Web Vitals: From Metrics to Fixes
Diagnostic Workflows for LCP, CLS, and INP
Part of The Production Frontend Engineer series. Prerequisites: FE-00 (browser internals, rendering pipeline), FE-02 (React performance).
Opening
After this chapter, you will never look at a slow page and say "it's just slow." You will look at it and say "the LCP image is being discovered late because it's in a lazy-loaded component, the server TTFB is eating 800ms, and there's a 3MB hero image with no dimension declarations causing a 0.3 CLS shift." You will have a diagnostic workflow — a sequence of questions that narrows from symptom to root cause — and you will know how to fix each root cause at the physical level. Web Vitals are not Google's arbitrary scoring system. They are measurements of specific physical events in the browser's rendering pipeline. Once you understand what those events are and why they happen when they do, the metrics stop being numbers to optimise and start being signals that tell you exactly what went wrong.
Part 1: Why These Three Metrics — The History You Need to Understand the Design
Before LCP, CLS, and INP existed, Google measured page speed with metrics like First Contentful Paint (FCP) and Time to Interactive (TTI). These were well-intentioned and both failed in practice, and understanding why they failed tells you exactly what LCP, CLS, and INP were designed to fix.
First Contentful Paint measured when the browser painted the first piece of DOM content — any content. A loading spinner is DOM content. A navigation bar is DOM content. A single pixel of a background image is DOM content. A page could score excellent FCP while the user stared at a spinning circle for 4 seconds waiting for the actual content. FCP told Google "something appeared," which turned out to be nearly useless as a proxy for "the user saw something meaningful."
Time to Interactive tried to measure when the page became fully usable — when the main thread was quiet, JavaScript had loaded, and event handlers were registered. The problem was its definition of "quiet": it required the main thread to have no long tasks (tasks over 50ms) for a 5-second window. On pages with lots of third-party scripts, this window was almost never reached, which produced TTI scores that were both too pessimistic and impossible to improve without removing features.
Google needed metrics that measured what users actually experience: did I see the content I came for? Did the page visually jump and make me click the wrong thing? When I tapped something, did it respond? That is exactly what LCP, CLS, and INP measure — and nothing more.
LCP (Largest Contentful Paint): when did the largest meaningful visual element finish painting? Proxy for "when did I see what I came for."
CLS (Cumulative Layout Shift): how much did the page visually jump while I was reading it? Proxy for "did the page trick me into clicking the wrong thing."
INP (Interaction to Next Paint): when I interacted with the page, how long until I saw visual feedback? Proxy for "does this page feel alive or dead?"
INP replaced FID (First Input Delay) in March 2024. FID only measured the delay before the browser started processing the first interaction — it did not measure how long the actual processing took. A tap could wait 0ms for the main thread (excellent FID) and then block for 2 seconds while React re-rendered a massive tree (catastrophic user experience, invisible to FID). INP measures the full round trip: input arrives → processing → next frame painted. It catches everything FID missed.
This is not trivia. When you are in an interview and someone asks "why INP over FID," this is your answer — not a memorised sentence, but the actual engineering reasoning behind the metric design.
Part 2: The Physics of Painting — What These Metrics Are Actually Measuring
You cannot diagnose Web Vitals problems without understanding what "paint" means at the physical level. This was covered in FE-00's rendering pipeline chapter, but you need it sharpened for this context.
The browser's rendering pipeline has five stages that matter here:
1. Parse HTML → build DOM tree
2. Parse CSS → build CSSOM tree
3. Combine → Render Tree (only visible nodes)
4. Layout → calculate pixel position and size of every node
5. Paint → fill pixels (CPU rasterises each layer into bitmaps)
6. Composite → GPU assembles layers into final frame → screen
"Paint" in Web Vitals terminology means the moment the browser completes stage 5 and 6 for a specific element — the moment pixels representing that element actually appear on screen. Not when the HTML was parsed. Not when the JavaScript ran. When the pixels were physically written to the display.
This matters because between "server sends HTML" and "pixels on screen," there are multiple physical bottlenecks, and each one corresponds to a different root cause for a poor LCP score:
Server responds slowly → high TTFB → delays everything downstream
Large HTML/CSS/JS blocks parser → parse delay → delays render tree construction
Resource discovered late → late fetch start → resource arrives late
Resource transfer is slow → large file size → pixels can't paint until bytes arrive
Resource decode is slow → large image → CPU busy decoding, can't paint
Layout is expensive → complex CSS → rasterisation starts late
Every LCP investigation is a walk through this pipeline, finding which stage is the bottleneck. The DevTools tools exist to measure each stage. Your job is to know which tool measures which stage.
Part 3: LCP — Largest Contentful Paint
What the Browser Is Measuring
The browser tracks every element that qualifies as a "largest contentful element" — images, video poster frames, block-level text elements with significant text. As the page loads and new elements paint, the browser updates its LCP candidate. The final LCP time is when the last candidate finished painting, before the user first scrolls or interacts.
Why the "before first scroll or interaction" condition? Because after the user interacts, they have already decided the page is usable enough to engage with. LCP is a measure of loading perception, not post-load performance.
Qualifying elements: <img>, <image> inside SVG, <video> with a poster, any block-level element containing text, background images loaded via CSS. Notably absent: <svg> elements without a background image, <video> elements without a poster attribute.
The most common LCP element in practice: a hero image or a large heading. If your hero image takes 3.8 seconds to paint, that is your LCP score.
The Four Root Causes of Poor LCP
Every poor LCP score traces to one or more of these four causes. Learn to identify which one without guessing.
Cause 1: Slow server (high TTFB)
Time to First Byte is how long from the browser sending the HTTP request to receiving the first byte of the HTML response. If TTFB is 1.2 seconds, every subsequent step in the pipeline is delayed by 1.2 seconds. LCP cannot be good if TTFB is bad.
How to measure: Chrome DevTools → Network tab → click the HTML document request → Timing section. The "Waiting for server response" duration is TTFB.
What causes high TTFB: slow database queries, no server-side caching, server not geographically close to the user (no CDN), cold serverless function startup (the classic Vercel cold start problem where a serverless function hasn't run recently and needs 200-400ms to initialise before it can even begin processing your request).
TTFB threshold: under 800ms is acceptable, under 200ms is good. Anything over 800ms will make your LCP nearly impossible to bring under 2.5 seconds.
Cause 2: Render-blocking resources
The browser cannot paint anything until it has finished building the DOM and CSSOM. Anything that blocks either of those blocks LCP.
CSS stylesheets are render-blocking by default. If your page has a <link rel="stylesheet" href="/styles.css"> in the <head>, the browser will not paint a single pixel until that stylesheet is downloaded, parsed, and the CSSOM is built. If /styles.css is 400KB and you are on a 3G connection, you are looking at 2+ seconds of nothing before LCP can even start.
JavaScript in the <head> without async or defer is parser-blocking. The HTML parser stops at the <script> tag, waits for the script to download and execute, then resumes. If a <script> tag appears before your hero image in the HTML, the image request cannot even start until the script finishes.
How to measure: DevTools → Lighthouse audit → "Eliminate render-blocking resources" section. Or Network tab with the waterfall view — any resource that is on the critical path before the LCP element's request starts is a suspect.
Cause 3: Slow resource load time
Assuming TTFB is fine and there are no render-blocking resources, the LCP element itself still needs to be downloaded and decoded before it can paint. For images, "decoded" means the CPU has decompressed the image file format (JPEG, PNG, WebP) into raw pixel data the GPU can rasterise.
A 3MB JPEG hero image: download time on a typical 4G connection (~5Mbps) ≈ 4.8 seconds. Just for the image. Even if your server responded in 100ms and your HTML parsed instantly, LCP would be 5 seconds.
The factors that determine image load time:
- File size (bytes transferred)
- Format (AVIF compresses 50% smaller than JPEG at equal quality; WebP compresses 30% smaller)
- Transfer protocol (HTTP/2 multiplexed vs HTTP/1.1 queued)
- Server location (CDN edge vs origin)
- Decode time (larger images take longer to decompress, even after download)
Cause 4: Resource discovered late
This one causes the most surprise. The image might be small (fast to download) and the server might be fast (good TTFB), but if the browser doesn't know the image exists until late, it cannot start downloading it early.
The browser's preload scanner is a secondary HTML parser that runs ahead of the main parser specifically to find resources early. It finds <img src="...">, <link rel="stylesheet">, and <script src="..."> before the main parser reaches them, and starts fetching immediately.
The preload scanner cannot find:
- Images set as CSS background-image (the browser must execute CSS before it knows the background image URL)
- Images loaded by JavaScript (the browser must execute JS before it knows the URL)
- Images inside lazy-loaded components (JavaScript must run to render the component before the browser sees the image)
This last one destroys many Next.js apps. A hero image inside a React component is not in the initial HTML. The browser receives the HTML, starts the preload scanner, doesn't see the image (it's not in the HTML, it's in a JS component), finishes downloading and executing JavaScript, React renders the component, and only now does the browser see the image URL and start downloading it. By then, you've burned 1-2 seconds that could have been spent transferring the image.
The fix: if an image is your LCP element, it must be in the initial HTML (server-rendered), not rendered by JavaScript. And it must have <link rel="preload" as="image" href="..."> in the <head> to hint the preload scanner even earlier.
The LCP Diagnostic Workflow
STEP 1: What is the LCP element?
→ Chrome DevTools → Performance tab → record page load
→ Find the "LCP" marker on the timeline
→ Click it → see which element is highlighted in the viewport
Or: in the browser console, run:
new PerformanceObserver(list => {
list.getEntries().forEach(entry => console.log(entry.element))
}).observe({ type: 'largest-contentful-paint', buffered: true })
STEP 2: What is the TTFB?
→ Network tab → HTML document → Timing → "Waiting for server response"
→ If > 800ms: fix the server before touching anything else
STEP 3: Is the LCP element in the initial HTML?
→ View Page Source (Ctrl+U) — not DevTools Elements (that shows post-JS DOM)
→ Search for the image URL or element
→ If not found: it's being rendered by JavaScript — move it to server-rendered HTML
STEP 4: When does the LCP resource request start?
→ Network tab → find the LCP image request
→ Check its start time in the waterfall
→ If it starts late (after JS has executed): late discovery — add preload hint
STEP 5: How long does the LCP resource take to download?
→ Network tab → LCP image → Timing → "Content Download" duration
→ If > 500ms: image is too large — compress, convert to AVIF/WebP, resize
STEP 6: Are there render-blocking resources before LCP?
→ Network tab → filter by "Doc" and "CSS" → check waterfall
→ Any CSS loaded synchronously in <head> is blocking
→ Fix: inline critical CSS, defer non-critical CSS, remove unused CSS
The Two Fixes That Move LCP The Most
Fix 1: fetchpriority="high" on the LCP image
<img
src="/hero.jpg"
alt="Hero"
width="1200"
height="600"
fetchpriority="high"
/>
The browser's resource prioritisation algorithm treats images as medium priority by default — it will download stylesheets, scripts, and fonts before images. fetchpriority="high" tells the browser: this image is the most important resource on the page. Download it before anything else.
The physical effect: the image request jumps to the front of the network queue. On a connection that can only handle a few simultaneous requests efficiently, this means the image bytes start arriving earlier, and LCP improves proportionally.
Fix 2: <link rel="preload"> in the <head>
<head>
<link rel="preload" as="image" href="/hero.jpg" fetchpriority="high" />
</head>
The preload scanner finds this immediately — before the HTML parser reaches the <body>. The image download starts in the first network round trip. Without this, for a server-rendered image, the browser must: download HTML, parse <head>, parse <body>, find <img src="/hero.jpg">, THEN start downloading. With preload, the image download starts during the HTML download. You save one full round trip of latency.
For Next.js: the <Image> component with priority={true} adds this preload hint automatically. priority={false} (the default) does not. Every above-fold image that is your LCP element must have priority={true}.
Part 4: CLS — Cumulative Layout Shift
What "Layout Shift" Is at the Physical Level
When the browser calculates layout — assigning pixel positions and sizes to every element — it produces a stable geometry. Then a late-loading resource arrives (an image, an ad, a font), the browser recalculates layout, and elements that were at one position are now at a different position. This is a layout shift.
The physical sequence of a layout shift:
1. Browser paints page with initial layout
→ User sees: [Heading] [Content paragraph] [Button]
2. Image loads (no dimensions declared)
→ Browser calculates image needs 300px of space
→ Browser pushes everything below down by 300px
→ Re-runs layout for the entire subtree
→ Re-paints affected elements
3. User sees: [Heading] [Image] [Content paragraph] [Button]
→ Content paragraph moved. User was about to click it.
→ They click the image instead.
The CLS score is calculated as: impact fraction × distance fraction, summed across all unexpected layout shifts. Impact fraction is what portion of the viewport was affected. Distance fraction is how far elements moved relative to the viewport height. A 0.1 score means layout shifts affected about 10% of the viewport by about 10% of the viewport height — the "good" threshold.
The Three Root Causes of CLS
Cause 1: Images without explicit dimensions
When the browser parses <img src="/hero.jpg"> with no width or height attributes, it reserves zero space. It renders everything else assuming the image takes no space. When the image downloads and the browser learns its dimensions, it has to reflow the entire page to insert the image's actual dimensions. Everything below the image jumps down.
The fix is trivial and permanent: always declare width and height on images. The browser uses these to reserve the correct space before the image loads. No reflow. No shift.
<!-- ❌ No dimensions: causes layout shift -->
<img src="/hero.jpg" alt="Hero">
<!-- ✓ Dimensions declared: space reserved, no shift -->
<img src="/hero.jpg" alt="Hero" width="1200" height="600">
For responsive images where the displayed size varies, use the aspect-ratio CSS property derived from the declared dimensions:
img {
width: 100%;
height: auto;
aspect-ratio: attr(width) / attr(height); /* browser computes this from declared attributes */
}
Next.js <Image> requires width and height props (or fill) precisely because of this — it enforces the pattern that prevents CLS.
Cause 2: Dynamically injected content above existing content
A cookie consent banner that appears after the page loads. An advertisement that loads 2 seconds after the page renders. A "you have 3 notifications" bar injected by JavaScript. All of these push existing content down, causing layout shifts.
The correct approach: reserve space for dynamic content before it loads. If you know a banner will appear, give its container a fixed height from the start. A cookie banner shouldn't surprise the layout — it should slide into a pre-allocated space.
/* Reserve space for the cookie banner before it loads */
.cookie-banner-container {
min-height: 60px; /* the banner's approximate height */
}
Or better: use position fixed/sticky for banners so they overlay content rather than pushing it.
Cause 3: Web fonts causing text reflow (FOUT)
When a custom font hasn't loaded yet, the browser renders text in a fallback font (Arial, Times, etc.). When the custom font loads, the browser swaps it in. Fonts have different metrics — different letter spacing, line height, x-height. The same text in a different font takes different space. Everything reflows.
This is called FOUT: Flash of Unstyled Text. It causes CLS because the text layout changes when the real font arrives.
The fix: font-display: optional tells the browser "if the font isn't available immediately, use the fallback and don't swap." Zero CLS but the user might see the fallback font on slow connections.
The better fix (what Next.js does with next/font): generate a size-adjust CSS property for the fallback font that precisely matches the custom font's metrics. The fallback font is scaled to take exactly the same space as the real font. When the real font swaps in, nothing moves because both fonts occupy the same space.
The CLS Diagnostic Workflow
STEP 1: Find what is shifting
→ Chrome DevTools → Performance tab → record the page load
→ Look for "Layout Shift" markers (red triangles) in the Experience track
→ Click each one → DevTools highlights the shifted elements in the viewport
Or: Rendering panel (DevTools → ⋮ → More tools → Rendering)
→ Enable "Layout Shift Regions" checkbox
→ Elements that shift are highlighted in blue as they shift
STEP 2: What caused the shift?
→ For each shifted element, ask:
- Did an image load without dimensions? → add width/height
- Did an element appear above this content? → reserve space or use overlay
- Did a font swap? → fix font-display strategy
STEP 3: Measure the score
→ Lighthouse audit shows total CLS score
→ "Avoid large layout shifts" section lists specific elements contributing to CLS
→ Each entry shows: element, shift score, reason
Part 5: INP — Interaction to Next Paint
Why INP Is Harder to Understand Than LCP and CLS
LCP and CLS are loading metrics — they measure things that happen as the page loads. They are relatively deterministic. INP is an interaction metric — it measures what happens when a user does something, which depends entirely on what your JavaScript is doing at the moment of interaction.
INP measures the time from when the browser receives an input event (click, tap, key press) to when the next frame containing the visual response to that input is painted. The 75th percentile across all interactions during a page visit is the INP score. This means a single slow interaction can tank your score even if 99 other interactions were instant.
The Physical Cause of Poor INP: The Main Thread
Every slow INP traces to the same root cause: the main thread was busy when the interaction arrived.
Recall from FE-00: the main thread handles JavaScript execution, style calculation, layout, and paint. It is a single-lane road. When an interaction (click) arrives, the browser wants to call your event handler, run React's re-render, calculate new layout, and paint the result. All of this must happen on the main thread.
If the main thread is currently busy — executing a long JavaScript task, running a heavy re-render — the interaction waits. The user's tap goes into an input queue. The browser finishes the current task. Then it dequeues the interaction, calls your handler, runs the re-render, paints. The time from tap to paint is the INP.
User taps button
↓
Browser checks main thread — busy (large JavaScript task running)
↓
Input queued
↓ ← this wait is "Input Delay" in INP breakdown
Current task finishes (50ms? 200ms? 500ms?)
↓
Browser processes interaction (calls your onClick handler)
↓ ← this is "Processing Time" in INP breakdown
React re-renders, style calculated, layout, paint
↓ ← this is "Presentation Delay" in INP breakdown
Next frame painted — INP recorded
INP = Input Delay + Processing Time + Presentation Delay. Each component has specific causes and specific fixes.
Breaking Down the Three Components
Input Delay — waiting for the main thread to be free.
Cause: long tasks. Any JavaScript task that runs for more than 50ms on the main thread. Common culprits:
- Third-party scripts (analytics, chat widgets, advertising — these run their own JS on your main thread)
- Large synchronous computations (sorting a 10,000-item array on every render)
- React hydration (mounting a complex app on page load blocks the main thread for potentially hundreds of milliseconds — every interaction during this window waits)
Fix: eliminate long tasks. Break large tasks into smaller chunks using scheduler.yield(). Load third-party scripts with async or defer them until after the page is interactive.
Processing Time — your event handler and the resulting re-render.
Cause: doing too much work in response to an interaction. A click handler that triggers a React state update that re-renders 200 components. A keydown handler that runs a regex on every character typed.
Fix: everything in FE-02. Reduce re-renders with memo, stabilise references with useMemo/useCallback, use useTransition for non-urgent updates, virtualise long lists.
Presentation Delay — layout and paint after the DOM changes.
Cause: changes that trigger expensive layout recalculation. Changing an element's width or height triggers a full layout pass. Changing transform does not — it's compositor-only.
Fix: prefer CSS properties that avoid layout. Use transform for position changes, opacity for visibility changes. Avoid reading layout properties (offsetWidth, getBoundingClientRect) after making DOM changes — this forces synchronous layout (layout thrashing from FE-00).
The INP Diagnostic Workflow
STEP 1: Identify slow interactions
→ Chrome DevTools → Performance tab → Interactions track
→ After recording, look for orange/red bars in the Interactions track
→ Each bar is one interaction; width = duration; click to see breakdown
STEP 2: What is the breakdown?
→ Click an interaction bar → see Input Delay / Processing / Presentation
→ Input Delay > 50ms: long task was running — find the task in the Main thread track
→ Processing > 50ms: your handler or React re-render is slow — profile the component tree
→ Presentation > 16ms: expensive layout or paint — look for layout-triggering CSS changes
STEP 3: Find the long task (if Input Delay is the problem)
→ Main thread track → find the red-flagged task (red triangle = long task > 50ms)
→ Expand it → bottom-up and call tree views show where time was spent
→ Common culprits: third-party scripts (look for non-your-domain call stacks),
React hydration (look for "commitRoot" or "performWork" in React internals),
large bundle parsing ("Compile Script" in the call tree)
STEP 4: Verify the fix
→ Record again after the fix
→ Interactions track should show shorter bars
→ INP score in the Performance summary should improve
The scheduler.yield() Pattern — Breaking Long Tasks
If you have unavoidable long work (processing a large dataset, parsing a complex file), break it into chunks that yield control back to the browser between chunks:
async function processLargeDataset(items) {
const results = []
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]))
// Every 50 items, yield to the browser
// This allows pending interactions to be processed
if (i % 50 === 0) {
await scheduler.yield()
// Execution resumes here after the browser has handled
// any pending input events and painted any pending frames
}
}
return results
}
Without scheduler.yield(), the loop is one continuous task. It blocks the main thread for its entire duration. With scheduler.yield(), the loop is split into many small tasks with browser-controlled gaps between them. Interactions that arrive during the gaps are handled immediately. INP improves.
scheduler.yield() is available in Chrome 115+. For broader support, the polyfill uses setTimeout(resolve, 0) which achieves similar (though not identical) behaviour.
Part 6: The Measurement APIs — Observing Vitals in Your Own Code
This is what separates engineers who can read Lighthouse from engineers who can set up Real User Monitoring (RUM) and alert on production regressions.
Lighthouse gives you lab data — measurements from a simulated environment with throttled network and CPU. It is useful for debugging but does not represent your actual users. A user on a high-end MacBook in London on fibre will see very different numbers from a user on a mid-range Android phone in Mumbai on 4G.
Field data — also called Real User Monitoring — collects measurements from actual users in production. The web-vitals library gives you this with minimal code:
import { onLCP, onCLS, onINP } from 'web-vitals'
function sendToAnalytics(metric) {
// Send to your analytics endpoint — Datadog, PostHog, your own DB
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
delta: metric.delta, // change since last report
id: metric.id, // unique to this page load
navigationType: metric.navigationType // 'navigate', 'reload', 'back-forward'
}),
keepalive: true, // ensures the request completes even if the page unloads
})
}
onLCP(sendToAnalytics)
onCLS(sendToAnalytics)
onINP(sendToAnalytics)
Under the hood, onLCP uses PerformanceObserver watching for largest-contentful-paint entries. onCLS watches for layout-shift entries. onINP watches for event entries and tracks the worst interaction duration across the page visit.
The raw API, if you want to understand what the library wraps:
// Observe LCP manually
new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1] // most recent candidate
console.log('LCP candidate:', lastEntry.startTime, lastEntry.element)
}).observe({ type: 'largest-contentful-paint', buffered: true })
// Observe layout shifts manually
let cumulativeCLS = 0
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) { // ignore shifts caused by user interaction
cumulativeCLS += entry.value
}
})
}).observe({ type: 'layout-shift', buffered: true })
buffered: true means the observer receives entries that happened before the observer was created — critical for metrics that happen early in the page load, before your JavaScript has executed.
Part 7: Lab Data vs Field Data — Why They Diverge and Why It Matters
Lighthouse (lab) can report LCP of 1.8 seconds. Google Search Console (field) reports your P75 LCP as 4.2 seconds. Both are correct. They are measuring different things.
Lab data: one measurement, one device (mid-tier emulated), one throttled network, one location (wherever Lighthouse runs), zero other tabs open. Perfectly reproducible. Useful for debugging specific issues.
Field data: P75 across all users — the 75th percentile means 75% of your users experience this or better. Your users have a distribution of devices (some great, some terrible), a distribution of network speeds, a distribution of locations. They have other tabs open, other programs running, some are on battery saver mode.
The gap between lab and field usually comes from:
Real device variation: a 2020 iPhone takes 0.3x as long to parse JavaScript as a low-end Android phone. A test on a MacBook M2 does not represent a user on a Redmi 8 with 3GB RAM.
Third-party scripts in production: Lighthouse can be run without ad scripts, tracking pixels, and chat widgets. Your real users load all of them. A single heavy third-party script can add 500ms+ of main thread blocking that never shows up in Lighthouse.
Cold vs warm caches: Lighthouse always starts with a cold cache. Real users often have warm caches — CSS, fonts, and common scripts may already be cached from a previous visit, making their experience significantly faster than lab data suggests.
Geographic distribution: if your server is in Virginia and 40% of your users are in Southeast Asia, their TTFB is 200-300ms higher than your lab measurements from the US.
The practical implication: fix Lighthouse issues, but deploy field monitoring to know your actual user experience. If you only have one of the two, field data is more important — it tells you what's real.
Part 8: Putting It Together — A Full Investigation
Here is a complete, real scenario. Work through this before reading the answers.
Scenario: Your SaaS product dashboard has an LCP of 4.8 seconds in the field (P75). Lighthouse on the same page reports 2.1 seconds. You've received user complaints about the page "feeling slow to load." CLS is 0.19 (poor). INP is 280ms (needs improvement). Walk through the full investigation.
Step 1: LCP investigation
Lab vs field gap of 2.7 seconds suggests real-device and/or geographic factors. Start with field data — find which user segments are worst. If mobile users are 3.5× worse than desktop, the device factor is primary. If users in certain regions are worse, geography/CDN is the factor.
Open the page in DevTools with CPU throttling 4× and network throttling "Slow 4G" (closer to real mobile conditions). Now Lighthouse should be closer to field data.
Check TTFB: if your server is on Vercel's serverless functions in us-east-1 and the user is in Mumbai, every request has 150-200ms of baseline latency. A server-side database query adds more. Check TTFB in the Network tab. If it is above 1 second, the server is your primary problem.
Check LCP element: is it an image? Is it in the initial HTML? View Page Source and search for it. If it's not there, React is rendering it client-side — move it to a Server Component.
Check image size: for an image identified as the LCP element, click it in the Network tab and check "Transfer Size." If it's above 200KB, it needs compression. If it's a JPEG, convert to WebP or AVIF. If it lacks fetchpriority="high", add it.
Step 2: CLS investigation
0.19 is in the "poor" range (above 0.1). Enable Layout Shift Regions in the Rendering panel. Load the page and watch for blue highlights. In a typical dashboard:
- The most common cause: a notification counter or user avatar that loads asynchronously and pushes down the navigation
- Second most common: an analytics chart that renders a placeholder then resizes to its data-driven height
For the navigation issue: give the nav a fixed height from the start so asynchronously loaded content slots in without shifting. For the chart: give the chart container an explicit min-height or aspect-ratio that matches the loaded chart dimensions.
Step 3: INP investigation
280ms is borderline. Record an interaction in the Performance tab. Look at the Interactions track. Find the interaction that drove the 280ms score.
For a dashboard, the most common cause: a filter change that triggers a React re-render of a large data table. The re-render re-processes and re-renders 200+ rows. Fix: wrap the filter update in useTransition — the input updates instantly (good INP), the table re-renders in the background (non-urgent).
Vocabulary Map
| Term | What it physically is |
|---|---|
| LCP | The timestamp when the browser finishes rasterising (drawing pixels for) the largest image or text block visible in the viewport |
| CLS | The sum of geometry-change events measured as (fraction of viewport affected) × (fraction of viewport displaced), across all unexpected shifts during page load |
| INP | The longest time from an input event arriving at the browser to the next frame being painted, measured at the 75th percentile across all user interactions during a page visit |
| FID | The time the input event spent waiting in the queue before the browser started processing it — INP's predecessor, replaced because it ignored processing and paint time |
| TTFB | The elapsed time from the browser sending an HTTP request to receiving the first byte of the response — includes DNS lookup, TCP handshake, TLS negotiation, and server processing |
| Render-blocking resource | A CSS stylesheet or synchronous <script> that prevents the browser from building the render tree and painting until the resource is downloaded and processed |
| Preload scanner | A secondary HTML parser that runs ahead of the main parser to discover resource URLs early so network requests can start before the main parser reaches those elements |
| Layout shift | A browser event where one or more DOM elements change their position or size after the initial paint, forcing the browser to recalculate geometry and repaint affected regions |
| Long task | Any JavaScript task that runs on the main thread for more than 50ms — the threshold where the browser misses at least 3 frames and the page feels unresponsive |
| Input delay | The component of INP that measures how long an interaction waited in the queue before the browser could begin processing it — caused by long tasks blocking the main thread |
| Field data / RUM | Performance measurements collected from actual users in production via JavaScript APIs — reflects the true distribution of user devices, networks, and geographic locations |
| Lab data | Performance measurements collected in a controlled simulated environment (Lighthouse) — reproducible but not representative of real user conditions |
fetchpriority | An HTML attribute that tells the browser's resource prioritiser to treat this resource as high or low priority relative to other resources competing for network bandwidth |
PerformanceObserver | A browser API that lets JavaScript code subscribe to performance timeline events (LCP candidates, layout shifts, long tasks) as they happen |
| Presentation delay | The component of INP from when your JavaScript finishes running to when the next frame containing the visual response is painted — includes style calculation, layout, and paint |
Practice Problems
Problem 1 — Failure Analysis
A Next.js e-commerce site has an LCP of 5.2 seconds in field data. The LCP element is the product hero image. TTFB is 180ms (good). The image is 85KB (well-optimised). The image has priority={true} on the Next.js <Image> component. There are no render-blocking CSS files. Lighthouse reports the same 5.2 seconds even on a fast connection. What is causing the LCP and where exactly in the pipeline do you look to confirm your hypothesis?
(Hint: you have ruled out TTFB, image size, render-blocking resources, and late discovery. What is left in the pipeline between "image bytes arrive" and "pixels on screen"?)
Problem 2 — Design from Scratch
You are building a news site that must achieve LCP under 1.5 seconds for users in India on 4G (typical RTT to your Singapore server: 80ms). The hero section contains a full-width image (design requires 1400px wide), a headline, and a subheadline. The site uses Next.js App Router. Your server is on Vercel (origin in Singapore, CDN in 100+ locations). Design the complete strategy for the hero section — rendering approach, image format and sizing, HTML attributes, and any additional network hints. Calculate approximately what LCP you would achieve with your design.
Problem 3 — Synthesis
A React dashboard has INP of 450ms. Profiling reveals: Input Delay = 10ms, Processing Time = 380ms, Presentation Delay = 60ms. The processing time comes from a React re-render that processes 500 table rows. You apply React.memo to the row component and the INP improves to 400ms — a small gain. Why did memo not help more, given that the processing time is 380ms? What does the remaining 400ms tell you about the root cause, and what is the correct fix?
Problem 4 — Go Deeper
CLS is measured as zero during Lighthouse testing but field data shows P75 CLS of 0.22. You know Lighthouse runs before user interactions. What categories of layout shift would be invisible to Lighthouse but visible to real users? Design a test procedure using only browser DevTools (no additional tools) that would capture these shifts — and explain why your procedure captures what Lighthouse misses.
Problem 5 — Previous Chapter Connection (FE-02)
A React SPA has good LCP (1.6s) and good CLS (0.02) but poor INP (520ms). The slow interaction is a search filter that triggers filtering of 2,000 items displayed in a list. You apply useTransition and INP improves to 180ms — back to "good." Three weeks later, the product team increases the dataset to 8,000 items and INP degrades to 480ms again even with useTransition. Why does useTransition stop being sufficient at 8,000 items, and what does this tell you about the relationship between React's concurrent features and the INP metric's "Presentation Delay" component specifically?
What's Coming Next
You now have a complete mental model for diagnosing and fixing runtime performance — what happens after the user navigates to your page. But the most impactful performance decisions happen before that: during the build. How your code is bundled, split, and delivered determines whether the browser spends 100ms or 4 seconds parsing and executing JavaScript before any of your runtime optimisations even begin to matter. FE-03 goes one level earlier in the pipeline — into the bundler, the module graph, and the strategies that control what gets delivered to the browser and when. The concepts in FE-03 will tell you why a perfect Lighthouse score can coexist with a 3MB JavaScript bundle, and why that bundle is often the first thing to fix.