Chapter 16
Web Performance and Core Web Vitals
LCP, INP, CLS, measuring, and optimising loading and runtime.
16. Web Performance
Web performance is the experience of how quickly a page becomes useful, how smoothly it responds, and whether it stays visually stable while loading. A fast server is valuable, but it is only one part of that experience: HTML, CSS, JavaScript, images, fonts, browser scheduling, and user input all affect what a person sees and can do. Good performance work therefore combines measurements with an understanding of the browser's rendering pipeline.
This chapter introduces the 2026 Core Web Vitals, distinguishes real-user field data from controlled lab tests, traces the critical rendering path, and develops practical techniques for images, fonts, layout stability, code splitting, and Chrome Performance profiling.
Overview & Learning Objectives
By the end of this chapter, you will be able to:
- Explain LCP, INP, and CLS and identify their “good” thresholds.
- Decide when to use Lighthouse, CrUX, and the
web-vitalslibrary. - Describe how HTML, CSS, JavaScript, and resources influence the critical rendering path.
- Improve image and font delivery without sacrificing accessibility or visual quality.
- Split JavaScript by route or interaction and avoid common loading regressions.
- Prevent layout shifts by reserving space and controlling late-arriving content.
- Use Chrome DevTools Performance recordings to form and test a performance hypothesis.
Core Web Vitals: Measure What People Experience
Google's Core Web Vitals are a small set of user-centered metrics used to describe loading, responsiveness, and visual stability. As of 2026, the current set is:
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| Largest Contentful Paint (LCP) | When the largest visible image or text block is rendered | ≤ 2.5 seconds | > 2.5 to 4 seconds | > 4 seconds |
| Interaction to Next Paint (INP) | Responsiveness across eligible interactions during a visit | ≤ 200 milliseconds | > 200 to 500 milliseconds | > 500 milliseconds |
| Cumulative Layout Shift (CLS) | Unexpected visual movement over a page's lifetime | ≤ 0.1 | > 0.1 to 0.25 | > 0.25 |
These cutoffs are evaluated at the 75th percentile: at least 75% of observed page visits should meet the “good” threshold for each metric, considering mobile and desktop separately where the data supports it. The goal is not to make every test score perfect. It is to improve the distribution for actual visitors, including people on slower devices and networks.
LCP: loading the main content
LCP marks the render time of the largest eligible content element visible in the viewport, such as a hero image, a poster image, or a substantial text block. The element can change as content arrives; the final LCP candidate is the largest eligible one rendered before the page is hidden or the user interacts. LCP is not simply “the load event,” nor does it measure every pixel on the page.
LCP often has four useful stages:
- Time to first byte: how long before the browser receives the first response bytes.
- Resource load delay: time before the browser discovers or prioritizes the LCP resource.
- Resource load duration: time spent downloading it.
- Element render delay: time between the resource being available and the element being painted.
This breakdown guides diagnosis. A late-discovered CSS background image may need to become an <img> or be preloaded carefully. A slow response may require server or cache work. An image downloaded quickly but displayed late may be blocked by render-blocking styles or main-thread JavaScript. Avoid automatically preloading every large image: an incorrect preload can compete with more important resources.
INP: responsiveness over the visit
INP estimates the latency of interactions such as clicks, taps, and key presses. It accounts for the time from input, through event processing and rendering work, until the next frame is presented. Unlike the retired First Input Delay (FID), INP considers the interaction distribution through a visit, rather than only the delay before the first event handler starts. Its value is generally the longest interaction latency, with a statistical adjustment that reduces the influence of outliers on pages with many interactions.
An interaction can be slow because of a long event handler, expensive synchronous computation, too much rendering, or other work occupying the main thread. Splitting a large task into smaller tasks can let the browser process input between them. Moving suitable computation to a Web Worker can free the main thread, but DOM reads and writes still belong on the main thread. Debouncing can help repeated input such as search, but do not use it to make essential feedback feel delayed.
CLS: visual stability
CLS summarizes unexpected layout movement. It is based on layout-shift scores: the impact fraction (how much of the viewport moved) multiplied by the distance fraction (how far it moved). The metric groups shifts into sessions with a bounded gap and maximum duration, then reports the largest session window rather than adding every shift for an entire long visit without limit.
User-initiated changes are treated differently from unexpected movement. For example, content that expands immediately after a user presses a disclosure button is expected; an ad inserted above a paragraph without reserved space is not. A low CLS does not prove every animation is comfortable, so inspect the experience as well as the aggregate number.
Field Data and Lab Data
Field data and lab data answer different questions. Neither replaces the other.
Field data is collected from real page visits across different devices, networks, locations, and behavior. Chrome User Experience Report (CrUX) is a public dataset derived from eligible Chrome users who have opted into usage statistics. Its history is useful for broad trends, but it can be delayed, grouped, or unavailable for pages with insufficient samples. CrUX does not expose an individual user's trace. Search Console and PageSpeed Insights can surface CrUX aggregates for eligible sites and URLs.
The web-vitals JavaScript library can measure supported Web Vitals in the browser and send results to an analytics endpoint. This provides more direct, URL-specific operational data and can include details useful for investigation. Capture only what your privacy policy permits; metrics do not require recording form values or sensitive content. A minimal example using the modern attribution build is:
import { onCLS, onINP, onLCP } from "web-vitals/attribution";
function sendVital(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
rating: metric.rating,
navigationType: metric.navigationType,
attribution: metric.attribution,
});
navigator.sendBeacon("/analytics/vitals", body);
}
onLCP(sendVital);
onINP(sendVital);
onCLS(sendVital);The endpoint should validate and aggregate data rather than treating arbitrary client metrics as trusted facts. sendBeacon is designed for small, non-blocking telemetry near page exit, but delivery is not guaranteed. For a simple production deployment, account for sampling, duplicate visits, consent, and the distinction between a navigation and a soft navigation in a single-page application. Read the library version's documentation because its supported attribution fields can evolve.
Lab data is produced in a controlled run, often with a selected device profile, CPU slowdown, and network throttling. Lighthouse runs audits in Chrome and reports performance opportunities and diagnostics. It is excellent for repeatable comparisons and for locating blocking resources or oversized assets. A Lighthouse score is not a field percentile and does not guarantee a visitor's Web Vitals. A local run may be affected by extensions, background processes, cache state, or machine speed.
Use field data to answer, “Are real visitors meeting our targets, and which routes or device groups are struggling?” Use lab data to answer, “What work in this reproducible page load is causing a problem, and did this code change improve it?” A practical loop is: identify a field regression, reproduce a representative route in a lab, inspect the trace, make one targeted change, then verify both the controlled result and the later field trend.
The Critical Rendering Path
The browser needs enough information to construct and paint a page. HTML is parsed into the DOM. CSS is parsed into the CSSOM. The DOM and CSSOM combine into a render tree for visible content; layout calculates geometry, paint creates drawing instructions, and compositing assembles layers for display. Modern browsers can overlap or optimize stages, and not every update repeats the entire pipeline, but this model is useful for reasoning.
External stylesheets usually block rendering because the browser cannot safely paint styled content until it knows the applicable rules. JavaScript can block HTML parsing when it is a classic parser-inserted script without async or defer; scripts may also need stylesheets to be available before execution if they can inspect computed style. defer scripts download during parsing and execute in document order after parsing. async scripts execute as soon as ready, with no guaranteed order; use it for independent scripts such as analytics, not dependent application modules.
<link rel="stylesheet" href="/assets/site.css">
<script defer src="/assets/app.js"></script>For module scripts, type="module" is deferred by default. Avoid adding async or defer mechanically without considering execution order and dependencies. The first improvement is often removing unnecessary work, not adding more hints.
Prioritizing the LCP resource
Make the LCP resource discoverable early in the HTML. Prefer an image element over a CSS background when the image is meaningful content and should be available to assistive technology:
<img
src="/images/course-dashboard-960.avif"
srcset="
/images/course-dashboard-640.avif 640w,
/images/course-dashboard-960.avif 960w,
/images/course-dashboard-1440.avif 1440w
"
sizes="(min-width: 72rem) 68rem, calc(100vw - 2rem)"
width="1440"
height="900"
fetchpriority="high"
alt="A course dashboard showing progress and upcoming lessons"
>fetchpriority="high" is a hint, not a guarantee. Reserve it for a true above-the-fold priority image, not every image on the page. An image preload can be useful when discovery is otherwise delayed, but ensure its URL and responsive candidate selection correspond to the actual image. A preload that downloads a different variant from the <img> can waste bandwidth.
Reduce CSS and JavaScript that prevent the first useful paint. Inline only genuinely small, critical CSS when it simplifies delivery; oversized inline styles delay HTML parsing and cannot be cached independently. Split non-critical styles or load below-the-fold content in a way that preserves a usable initial view. Avoid blocking on third-party scripts that are not essential to the first interaction.
Images: Right Size, Format, and Priority
Images commonly dominate page bytes. Choose the image's pixel dimensions based on its displayed size and likely device pixel ratio, then provide appropriate candidates. srcset and sizes let the browser choose a candidate; sizes describes the rendered slot width, not the file's intrinsic width. Incorrect sizes may cause the browser to download a much larger image than needed.
For example, a card that is one column on small screens and one third of a wide content region can declare:
<img
src="/images/lesson-640.webp"
srcset="/images/lesson-320.webp 320w, /images/lesson-640.webp 640w, /images/lesson-960.webp 960w"
sizes="(min-width: 68rem) 20rem, (min-width: 42rem) calc((100vw - 4rem) / 2), calc(100vw - 2rem)"
width="960"
height="600"
loading="lazy"
alt="A learner practicing a layout in a code editor"
>Do not lazy-load an image likely to be the LCP image. loading="lazy" is for offscreen resources that should wait until near the viewport; applying it to the hero can delay LCP. Below-the-fold editorial images are good candidates. Use <picture> when offering modern formats with a fallback or when art direction changes the crop:
<picture>
<source media="(min-width: 60rem)" srcset="/images/team-wide.avif" type="image/avif">
<source srcset="/images/team-portrait.webp" type="image/webp">
<img src="/images/team-portrait.jpg" width="800" height="1000" alt="A team planning a project">
</picture>Use an empty alt="" for purely decorative images. For informative images, describe the relevant information rather than the file or visual style. Compression and format choices should preserve detail at the actual rendered size; evaluate photographs, illustrations, and interface screenshots differently.
Fonts and font-display: swap
A web font can delay text rendering or change line widths after it loads. Use a carefully subsetted font, serve modern compressed font files, and request only weights and styles actually used. Prefer self-hosting when it improves reliability, privacy, and connection setup; a third-party font service may still be a valid choice, but it adds another dependency.
@font-face {
font-family: "Field Sans";
src: url("/fonts/field-sans-latin.woff2") format("woff2");
font-style: normal;
font-weight: 400 700;
font-display: swap;
}
body {
font-family: "Field Sans", system-ui, sans-serif;
}font-display: swap allows a fallback face to appear quickly while the web font loads, then swaps to the web font if it becomes available. This avoids invisible text during the block period, but the replacement can alter line wrapping and cause movement. Choose a metrically compatible fallback and tune its metrics with supported size-adjust, ascent-override, descent-override, and line-gap-override descriptors when appropriate. Check actual browsers and fonts; metric overrides are a calibration tool, not a substitute for testing.
Preload a font only when it is needed immediately and is used on the current route. Include the correct crossorigin mode for a cross-origin font request, including many same-origin font preloads:
<link
rel="preload"
href="/fonts/field-sans-latin.woff2"
as="font"
type="font/woff2"
crossorigin
>Preloading every weight or script subset can compete with the LCP image and waste bandwidth. font-display: optional may avoid a late swap on some visits, but the font may not appear in that navigation; choose based on the design's needs and test stability.
JavaScript Delivery and Bundle Splitting
JavaScript has download, parse, compile, execution, and memory costs. A small compressed file can still consume substantial CPU on a low-end phone. The most effective optimization may be removing an unnecessary dependency, deferring a feature, or rendering less—not merely compressing the same work.
Split code along meaningful boundaries. Route-level splitting allows a visitor to download only the route code they need. Interaction-level splitting can defer an editor, charting library, or modal until the user opens that feature:
const openEditorButton = document.querySelector("#open-editor");
openEditorButton.addEventListener("click", async () => {
const { mountEditor } = await import("./editor.js");
mountEditor(document.querySelector("#editor-host"));
});In a component framework, use its supported lazy-loading mechanism and show an accessible loading state. Dynamic imports introduce a network wait, so defer optional work, not code essential to the first interaction. Provide a useful fallback and handle failed chunk requests, which can occur after a deployment invalidates an old page's asset references.
Audit production bundles with the framework's analyzer. Look for duplicate libraries, broad imports, large locale datasets, and code that is only used on one route. Tree shaking depends on static module structure and package metadata; side effects can prevent unused exports from being removed. Bundle splitting can also create too many small requests, waterfall dependencies, or duplicated shared code. Measure the rendered route, not only the size of a generated file.
For long tasks, reduce synchronous work and yield between independent chunks where appropriate:
async function processItems(items) {
for (let start = 0; start < items.length; start += 100) {
processBatch(items.slice(start, start + 100));
await new Promise((resolve) => setTimeout(resolve, 0));
}
}This simple yield schedules later work as a task and gives the browser a chance to handle input or render. It is not a universal scheduler; scheduler.yield() can be considered where supported, with a fallback strategy. If computation is independent of the DOM and genuinely expensive, a Worker may be more appropriate. Never assume requestAnimationFrame makes heavy work free: code inside it still runs on the main thread before a paint.
Preventing Layout Shifts
Reserve space for content whose final dimensions are not known at initial render. Width and height attributes on images establish an aspect ratio before download:
<img src="/images/diagram.png" width="1200" height="800" alt="A diagram of the rendering pipeline">CSS can then size the image responsively while preserving the ratio:
.diagram {
display: block;
width: 100%;
height: auto;
}For video, embeds, and placeholders, use an aspect ratio:
.video-frame {
aspect-ratio: 16 / 9;
background: #111;
}
.video-frame iframe {
width: 100%;
height: 100%;
border: 0;
}Reserve a minimum-height or fixed slot for ad placements if the product uses them. Avoid inserting banners above content after it has rendered; if a banner must appear, overlay it without displacing content only when that does not obscure controls or violate accessibility. For consent prompts, announce changes and consider whether a reserved location or non-displacing panel is appropriate.
Do not hide all text until a custom font loads. Keep dimensions stable when expanding accordions or loading personalized recommendations. Skeletons should match the final content's approximate geometry; animated skeleton shimmer is decorative, so respect reduced-motion preferences. CSS transforms can animate without changing layout, but motion that surprises users can still be disorienting. Use prefers-reduced-motion for nonessential movement:
@media (prefers-reduced-motion: reduce) {
.loading-indicator {
animation: none;
}
}Profiling with Chrome DevTools
Start with a question, not a recording. Examples include “Why is the hero visible late on mobile?” or “What blocks the main thread after clicking Search?” Reproduce the relevant route with representative content and viewport settings. Open DevTools, select Performance, enable screenshots if useful, and record a page load or interaction. For repeatability, use a consistent network and CPU profile, and note whether the cache was cold or warm.
The Performance panel's overview helps locate long tasks, layout shifts, and delayed rendering. Inspect the Main thread flame chart and event details. Tasks longer than 50 milliseconds are generally marked as long tasks; a cluster of them can delay input even when no single task appears dramatic. Expand scripting, style recalculation, layout, and paint events. Use the Bottom-up view to identify the most expensive functions and Call tree to see what triggered them. Screenshots and filmstrip frames show when meaningful content appeared.
For LCP, inspect the Timings track and element details, then determine whether the delay is server response, resource discovery, transfer, or rendering. For INP investigations, record a representative click or typing interaction and inspect event timing, handlers, and the next rendering work. For CLS, use layout-shift markers and their affected nodes to identify which elements moved and what preceded the shift. A layout-shift region can reveal a late font, image, injected message, or changing content.
Chrome's Coverage panel can identify loaded JavaScript and CSS that was not used during a particular recording. It is a clue, not proof that the code is dead: another route or interaction may need it. Lighthouse can provide an initial audit and suggested opportunities, but inspect the actual trace before making a change. A warning such as “reduce unused JavaScript” does not tell you which feature is safe to remove or split.
Use this disciplined cycle:
- Record the baseline and save the trace.
- Identify the largest relevant bottleneck, not every warning.
- Form a hypothesis that predicts an observable change.
- Make one focused change.
- Repeat the same scenario and compare traces and metrics.
- Validate the real-user distribution after deployment.
Do not optimize based only on a developer laptop or a single synthetic score. Keep performance budgets aligned with product goals, such as a maximum route JavaScript transfer size, an LCP target, or a limit on third-party scripts. Budgets should prompt investigation rather than encourage hacks that harm usability.
Exercises
Exercise 1: Diagnose a slow course landing page
A course landing page has a large hero image, two font weights, a chart library loaded on every route, and a third-party review widget. In a mobile field report, LCP is 3.8 seconds and INP is 260 ms. Describe a measurement plan and propose targeted changes. Include a correctly prioritized hero image, font strategy, and a plan for handling the chart.
Solution
First segment field measurements by route, device class, and navigation type. Check whether the page's LCP element is the hero image or a text block, and compare the page's CrUX trend with first-party web-vitals data if available. Use a Lighthouse mobile run and a Performance trace under a consistent throttled profile to inspect LCP subparts. Record a representative interaction that exercises the review widget or chart before attributing the 260 ms INP to either one.
Expose the hero as an early-discovered <img> with accurate srcset, sizes, intrinsic width and height, descriptive alternative text, and fetchpriority="high" if it is truly the LCP candidate. Do not lazy-load it. Avoid a preload unless the trace shows discovery delay and the preload matches the chosen image candidate. Provide compressed responsive variants rather than sending a desktop-sized file to every phone.
Subset the two used font weights into WOFF2 files and define them with font-display: swap. Preload only the weight needed above the fold, then assess whether the fallback causes line-wrap shifts. Choose or tune a metrically compatible fallback if the heading moves substantially. The chart should be dynamically imported when its section approaches the viewport or when a user opens it, provided the chart is not essential to the initial content. If the chart needs data computation, inspect its main-thread work and consider a worker for suitable non-DOM processing. Keep the review widget from blocking the initial render; load it after the core content or on explicit interaction, with a stable reserved slot. Retest the same mobile scenario, then observe field percentiles after release.
Exercise 2: Find and fix a layout shift
A product page has a CLS of 0.19. Images have no dimensions, a promotional message is inserted above the title 1.5 seconds after load, and a custom font replaces the fallback. Give a Chrome DevTools investigation plan and implement a stable layout strategy.
Solution
Record a page load in the Performance panel with screenshots and inspect layout-shift markers. Select each marker to see affected nodes and the preceding event. This distinguishes image expansion from the promotional insertion and font swap. Reproduce with a cold cache so that late assets are visible. Confirm the final field measurement rather than relying solely on this one trace.
Add accurate intrinsic dimensions to each image and responsive CSS that maintains its aspect ratio. Reserve a fixed or minimum-height slot for the promotion before it arrives, or place it in a non-displacing region; do not insert it above already-rendered content. If the promotion is optional, avoid adding it after a delay unless there is a clear product reason. Keep text visible with font-display: swap, select a metrically compatible fallback, and use font metric overrides only after checking the actual font metrics. Retest with network throttling and verify that headings and controls remain in place. These fixes address the causes; hiding layout-shift indicators or disabling the font would merely conceal symptoms.