Phase 2: Performance Engineering
Core Web Vitals (CWV) are Google's user-experience signals that directly influence ranking. But the word "vitals" is earned: these metrics measure real friction — slow loads, janky interactions, and visual instability — that hurt users before they ever hurt your rankings. This module treats CWV as an engineering problem, not a dashboard problem. You will learn what each metric measures at a technical level, why it fails, and how to fix it with precision.
Google introduced CWV as a ranking signal in 2021 via the Page Experience update. The three current metrics are:
Each metric has three bands: Good, Needs Improvement, and Poor. Google's ranking boost applies when the majority of page views land in the "Good" band. More importantly, these metrics correlate directly with conversion rate and bounce rate — fixing them helps users and search engines simultaneously.
A critical mindset shift: do not optimize to pass a Lighthouse score. Lighthouse is a lab tool — it runs in a synthetic environment on a single machine. The signal that matters for ranking and for users is field data: real-user measurements from the Chrome User Experience Report (CrUX). You will almost always encounter a gap between lab and field. Understanding why is part of advanced CWV work.
LCP measures the render time of the largest image or text block visible in the viewport, relative to when the page first started loading. It is a proxy for "when does the user perceive the page as loaded?"
Eligible LCP elements include:
<img> elements (including those inside <picture>)background-image (with caveats)<video> poster imagesEvery LCP failure traces back to one or more of these four causes:
The browser cannot render anything until it receives the first byte of HTML. A server that takes 1.5 seconds to respond puts you behind before the browser has parsed a single tag. TTFB is the foundation of LCP — fixing it improves everything downstream.
Fixes:
By default, the browser pauses HTML parsing when it encounters a <link rel="stylesheet"> or a synchronous <script> in <head>. Every millisecond spent downloading and parsing these resources is a millisecond the LCP element cannot render.
Fixes:
<head>media trick:<link rel="stylesheet" href="styles.css"
media="print" onload="this.media='all'">
defer or async to non-critical scriptsThis is the single most common LCP failure. The LCP element is an image, but the browser discovers it late because it is lazy-loaded, loaded via JavaScript, or has no preload hint.
The most impactful fix in all of CWV optimization:
loading="lazy", the browser intentionally delays fetching it. Remove that attribute for the above-the-fold image:
<!-- WRONG: lazy-loading a hero image -->
<img src="hero.webp" loading="lazy" alt="Hero">
<!-- CORRECT: eager loading (the default) -->
<img src="hero.webp" loading="eager" fetchpriority="high" alt="Hero">
<link rel="preload"> in <head> for the LCP image so the browser discovers it during the preload scanner, before it has parsed the body:<link rel="preload" as="image"
href="hero.webp"
imagesrcset="hero-400.webp 400w, hero-800.webp 800w"
imagesizes="100vw">
fetchpriority="high" attribute on the LCP image element itselfbackground-image — the browser cannot discover it until after CSS is parsed and the CSSOM is builtEven a correctly discovered image will be slow if it is oversized, uncompressed, or in an inefficient format.
Fixes:
srcset and sizes so mobile devices do not download a 2000px imagewidth and height attributes (also helps CLS)Open Chrome DevTools → Performance panel → record a page load. In the timeline, look for the green "LCP" marker. Click it to identify the LCP element. In the details pane you will see a breakdown:
The longest segment tells you exactly which of the four root causes to address first.
INP measures the latency of user interactions — how long it takes from when a user clicks, taps, or presses a key to when the browser visually responds (the next paint). It replaced First Input Delay (FID) as a Core Web Vital in March 2024. FID only measured the delay before the browser started handling the first interaction. INP measures the full duration of every interaction throughout the page's lifetime and reports the worst-case (or near-worst-case) interaction.
Every interaction has three phases:
INP = input delay + processing time + presentation delay. Your goal is to minimize all three.
Any JavaScript task that runs for more than 50ms is a "long task." While a long task runs, the browser cannot respond to user input — this is input delay. The most common sources:
Fixes:
scheduler.yield() (new) or the classic setTimeout(fn, 0) pattern to yield to the browser between chunks of work:
// Yield control back to the browser between chunks
async function processItems(items) {
for (const item of items) {
processItem(item);
// Yield after each item so the browser can handle input
await scheduler.yield();
}
}
Even when input delay is low, processing time can be long if event handlers do too much work synchronously — for example, filtering and re-rendering a large list on every keystroke.
Fixes:
offsetHeight after a DOM write forces a reflow)content-visibility: auto on off-screen content to reduce render workAfter the handler runs, the browser must recalculate styles, lay out the page, and paint. If the DOM is large or complex, this takes significant time.
Fixes:
contain: layout style) to scope style/layout recalculationswill-change: transform (use sparingly)
INP is a field metric only — it requires real user interactions, so Lighthouse cannot measure it accurately (it will show a simulated estimate). Use the web-vitals JavaScript library to capture real INP data from your users:
import { onINP } from 'web-vitals';
onINP(({ value, entries, attribution }) => {
console.log('INP:', value, 'ms');
// Send to your analytics endpoint
sendToAnalytics({ metric: 'INP', value, attribution });
});
The attribution object tells you which element was interacted with, which phase took the longest, and which event handler ran. This is your primary debugging signal for INP in production.
CLS measures unexpected visual movement of page content during its lifetime. If a button you are about to click suddenly jumps 200px because an ad loaded above it, that is a layout shift. CLS is scored by multiplying the impact fraction (how much of the viewport moved) by the distance fraction (how far the element moved). Shifts that result from user interaction (within 500ms) are excluded.
This is the most common CLS cause. When the browser parses an <img> tag with no width and height, it allocates zero space. When the image loads, it pushes all surrounding content down.
The fix: Always set explicit width and height attributes matching the image's intrinsic dimensions. Modern browsers use these to compute aspect ratio and reserve space before the image loads:
<!-- WRONG: no dimensions, browser reserves zero space -->
<img src="product.webp" alt="Product">
<!-- CORRECT: dimensions set, browser reserves aspect-ratio space -->
<img src="product.webp" width="800" height="600"
alt="Product" loading="lazy">
Or use the CSS aspect-ratio property for responsive images:
img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3; /* Reserves space before the image loads */
}
Ads, banners, cookie consent bars, and chat widgets that load after the initial HTML and push content down are a primary source of CLS in the wild. Any content inserted above existing content creates a shift.
Fixes:
min-height matching the expected ad sizeFlash of Unstyled Text (FOUT) occurs when the browser renders text in a fallback font, then swaps to the custom web font when it loads — shifting the layout because the two fonts have different metrics (line height, character width). Flash of Invisible Text (FOIT) hides text until the font loads, which hurts LCP.
Fixes:
font-display: optional for the best CLS behavior (the font only swaps in if it loads within a very short window; otherwise the fallback is used permanently for that page view)font-display: swap if you need the custom font to always appear, but pair it with the size-adjust / ascent-override / descent-override CSS descriptors to make your fallback font metrics match your custom font, minimizing the shift<link rel="preload" as="font"
href="/fonts/inter.woff2"
type="font/woff2" crossorigin>
CSS animations or transitions that animate properties like height, top, margin, or padding cause reflows and can contribute to CLS. Only transform and opacity animations are composited on the GPU without triggering layout.
Fix: Use transform: translateY() instead of animating top or margin-top. Use transform: scale() instead of animating width/height.
In Chrome DevTools → Performance panel, enable "Layout Shift Regions" in the Rendering tab (toggle via the three-dot menu). Sections of the page that shift will be highlighted in blue when you play back the recording. Click on a layout shift entry in the Experience row of the flame chart to see the element that shifted, the score, and the timestamp.
The LayoutShift entries in the Performance timeline also show the sources — the specific DOM nodes that moved — which is the fastest path to the root cause.
This is one of the most practically important distinctions in CWV work, and it confuses many practitioners.
| Lab Data | Field Data | |
|---|---|---|
| Source | Lighthouse, WebPageTest (synthetic) | CrUX / PageSpeed Insights field tab / GSC CWV report |
| Represents | One test run, one device, one network | Real users on real devices and real networks |
| INP support | Estimated only (no real interactions) | Actual interactions measured |
| Use for | Diagnosing and testing fixes in development | Ranking signal; business decisions; proving improvement |
| Minimum data requirement | None — runs instantly | 28-day rolling window; needs sufficient traffic to populate |
Key principle: Use lab tools (Lighthouse, DevTools, WebPageTest) to diagnose and iterate. Use field data (PageSpeed Insights field tab, Google Search Console Core Web Vitals report, your own RUM via the web-vitals library) to confirm that your fixes worked for real users and to make decisions about ranking impact.
Common reasons lab and field disagree:
Understanding LCP and INP at a deep level requires understanding the browser's Critical Rendering Path (CRP). Here is the full sequence from URL to pixels:
Every CWV optimization ultimately comes down to shortening one or more steps in this sequence:
Resource hints tell the browser to do work early, before it would naturally discover the need.
<link rel="preconnect"> — establishes a DNS + TCP + TLS connection to an origin before the browser has a resource to fetch from it. Use for critical third-party origins (fonts, CDN, analytics):
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload"> — fetches a specific resource at high priority as soon as the browser sees the hint, before it would normally discover it. Use for the LCP image, critical fonts, and late-discovered render-blocking CSS:
<link rel="preload" as="image" href="hero.webp"
fetchpriority="high">
<link rel="preload" as="font" href="/fonts/inter.woff2"
type="font/woff2" crossorigin>
<link rel="prefetch"> — fetches a resource the user is likely to need for the next navigation, at low priority, without affecting the current page load. Use for next-page assets, not current-page critical resources.
<!-- Responsive image with WebP and AVIF, sized for viewport -->
<picture>
<source type="image/avif"
srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
sizes="(max-width: 600px) 100vw, 800px">
<source type="image/webp"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, 800px">
<img src="hero-800.jpg"
width="800" height="533"
alt="Hero image"
loading="eager"
fetchpriority="high">
</picture>
Large JavaScript bundles are the primary cause of long tasks that hurt both LCP (parse/execute during initial load) and INP (parse/execute during interaction). Modern bundlers (Webpack, Rollup, esbuild, Vite) support:
import() is the mechanism:// Load the modal component only when the user opens it
button.addEventListener('click', async () => {
const { Modal } = await import('./modal.js');
new Modal().open();
});
import/export)Third-party scripts (analytics, tag managers, ads, A/B testing tools, chat widgets) are responsible for a disproportionate share of CWV failures in the wild. They run on your main thread but you do not control their code.
Strategies:
defer or load them after the page's load event firesWeb Worker using Partytown — this moves their execution off the main threadA professional CWV audit follows this sequence:
web-vitals library in production — instrument your real pages to capture actual INP, LCP, and CLS values with attribution data and send them to your analytics.
fetchpriority="high" and no loading="lazy"<head> with <link rel="preload">background-imageload eventwidth and height attributesmin-height)font-display: optional or font-display: swap with metric overridestransform and opacity (not layout-triggering properties)You are ready to progress when you can do all of the following without looking things up:
loading=lazy and no preload hint; remove the lazy attribute, add fetchpriority=high, and add a <link rel=preload> in head. The CLS is caused by the ad unit at coordinates 0,300 with no reserved height — add min-height: 250px to its container."