Step 20 of 21

Web performance

Measure Core Web Vitals in the lab and the field, then fix slow loading, layout shifts and sluggish interactions.

18 min 8 sections

Slow pages lose visitors, jumping layouts cause mis-clicks, and laggy buttons make an app feel broken. Google's Core Web Vitals give you a concrete metric for each of those experiences, and they influence search ranking. In this lesson you will learn what each metric measures, how to measure it properly and the specific changes that improve it.

The three Core Web Vitals

MetricMeasuresGoodPoor
LCP, Largest Contentful PaintLoading: when the largest image or text block in the viewport is rendered≤ 2.5 s> 4.0 s
INP, Interaction to Next PaintResponsiveness: delay from a click, tap or key press until the next frame is painted≤ 200 ms> 500 ms
CLS, Cumulative Layout ShiftVisual stability: how much visible content unexpectedly moves≤ 0.1> 0.25

Values in between count as "needs improvement". A page passes when the 75th percentile of real visits is good for all three: a fast experience on your laptop is not enough.

A few details matter when you read these numbers. INP replaced the older First Input Delay metric in March 2024; unlike FID, it considers every interaction on the page and reports roughly the slowest one. CLS reports the largest burst of layout shifts, scored by how much of the viewport moved and how far, and it ignores shifts within half a second of a user's click or key press, because expected movement is fine. Two supporting metrics help diagnose LCP: TTFB (Time to First Byte) and FCP (First Contentful Paint, when anything first appears).

Measuring: lab and field data

Lab data comes from a controlled test: Lighthouse in Chrome DevTools, or the DevTools Performance panel with CPU and network throttling. It is reproducible and great for debugging, but it runs one page load on one simulated device with no real user. Lighthouse cannot measure INP during a page load at all; it reports Total Blocking Time as a proxy for how busy the main thread is.

Field data comes from real users on real devices and networks. Google's Chrome User Experience Report (CrUX) publishes it for popular sites, and PageSpeed Insights shows CrUX data next to a Lighthouse run. For your own app, collect it yourself with the small web-vitals library:

ts
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals'

function report(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,     // "LCP", "INP" or "CLS"
    value: metric.value,   // milliseconds, or a unitless score for CLS
    rating: metric.rating, // "good", "needs-improvement" or "poor"
    page: location.pathname,
  })
  navigator.sendBeacon('/analytics/vitals', body)
}

onCLS(report)
onINP(report)
onLCP(report)

sendBeacon survives the page closing, which is when CLS and INP are usually finalised. Importing from web-vitals/attribution adds details such as which element was the LCP.

Improving LCP

Break LCP into four parts and find which one dominates: TTFB, resource load delay (time before the browser starts downloading the LCP image), resource load duration (the download itself) and element render delay (time from download to paint). The DevTools Performance panel shows this breakdown for the LCP element.

Make the LCP resource discoverable early. The browser's preload scanner finds <img src> in the HTML almost immediately, but it cannot see images set in CSS backgrounds or rendered by JavaScript. In a client-rendered single-page app, the hero image does not exist until your bundle has downloaded and run, which is a major reason server rendering or prerendering improves LCP.

Prioritise it, and do not lazy-load it. loading="lazy" on the hero image delays the most important download on the page. Mark it high priority instead:

html
<img
  src="/hero-1200.avif"
  srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2000.avif 2000w"
  sizes="100vw"
  width="1200" height="600"
  fetchpriority="high"
  alt="Spring collection: linen shirts in five colours">

When the image cannot be in the initial HTML, tell the browser about it from the <head>:

html
<link rel="preload" as="image" href="/hero-1200.avif" fetchpriority="high"
      imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2000.avif 2000w" imagesizes="100vw">

Shrink the download. Serve modern formats (AVIF or WebP are typically far smaller than JPEG at similar quality), let srcset and sizes pick an appropriately sized file for each screen, and compress text assets with Brotli or gzip on the server or CDN.

Remove render-blocking work. Stylesheets and synchronous <script> tags in the <head> block rendering. Keep critical CSS small and load scripts with type="module" or defer.

Fix slow servers. If TTFB alone is over a second, cache HTML at a CDN, speed up the backend or prerender pages that rarely change.

Improving CLS

Layout shifts happen when something appears or resizes after surrounding content has already been laid out.

Always reserve space for media. Give every <img> and <video> width and height attributes. Browsers use them to compute the aspect ratio before the file arrives, even when CSS makes the image responsive:

css
img {
  max-width: 100%;
  height: auto; /* keeps the ratio from the width/height attributes */
}

.embed {
  aspect-ratio: 16 / 9; /* reserve space for an iframe or ad slot */
}

Reserve space for late content. Banners, ads and embeds loaded after the page renders need containers with a fixed or minimum height. Never insert content above what the user is reading unless they asked for it.

Tame web fonts. When a web font replaces a fallback font of a different size, text reflows. font-display: optional avoids the swap on slow connections; alternatively, adjust the fallback font's metrics with size-adjust, ascent-override and descent-override in an @font-face rule so the swap barely moves anything.

Animate with transform. Animating top, left, width or height moves layout; transform: translate() and scale() do not cause layout shifts.

Improving INP

Each interaction has three phases: input delay (waiting for the main thread to be free), processing (your event handlers and the resulting render) and presentation delay (layout and paint of the next frame). The common root cause is a long task: any main-thread task over 50 ms. While it runs, the browser cannot respond to input.

Break up long tasks. If you must process lots of data on the main thread, yield control back to the browser periodically so it can handle input in between:

js
function yieldToMain() {
  if (globalThis.scheduler?.yield) return globalThis.scheduler.yield()
  return new Promise((resolve) => setTimeout(resolve, 0))
}

async function processAll(items) {
  let lastYield = performance.now()
  for (const item of items) {
    processItem(item)
    if (performance.now() - lastYield > 50) {
      await yieldToMain()
      lastYield = performance.now()
    }
  }
}

Do the urgent part first. Give immediate visual feedback, such as a pressed state or spinner, then defer non-urgent work. In React, useDeferredValue lets an input update immediately while an expensive list re-renders with the new value in the background, where its rendering can be interrupted:

tsx
import { memo, useDeferredValue, useState } from 'react'

const ResultList = memo(function ResultList({ query }: { query: string }) {
  // expensive: filters and renders thousands of rows
  return <ul>{/* ... */}</ul>
})

export function Search() {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)
  return (
    <>
      <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList query={deferredQuery} />
    </>
  )
}

The memo is essential: without it, the urgent re-render of Search would re-render the slow list too, even with the old query.

Render less. Huge DOMs make every style recalculation and layout slower. Virtualise long lists so only visible rows exist in the DOM, and avoid reading layout (offsetHeight, getBoundingClientRect) right after writing styles in a loop, which forces repeated synchronous layouts.

Shipping less JavaScript

Byte for byte, JavaScript is the most expensive resource: it must be downloaded, parsed, compiled and executed on the main thread.

Split by route and by feature. Load code when it is needed with dynamic import(). React's lazy and Suspense wrap this for components:

tsx
import { lazy, Suspense } from 'react'

const SettingsPage = lazy(() => import('./pages/SettingsPage'))

export function SettingsRoute() {
  return (
    <Suspense fallback={<p>Loading settings…</p>}>
      <SettingsPage />
    </Suspense>
  )
}

lazy expects the module's default export. For a named export, map it: lazy(() => import('./Chart').then((m) => ({ default: m.Chart }))).

Know what is in your bundle. A visualiser such as rollup-plugin-visualizer shows what each dependency costs. Replace heavy libraries with lighter ones or native APIs (for example Intl.DateTimeFormat instead of a date library just to format dates), and import specific functions so tree shaking can remove the rest.

Cache aggressively. Vite's hashed filenames let you serve assets with Cache-Control: public, max-age=31536000, immutable, so returning visitors download nothing. Serve the HTML itself with Cache-Control: no-cache, so the browser revalidates it and picks up new asset URLs after each deploy.

Practice

This dashboard scores badly: LCP of 4.8 s, CLS of 0.3 and INP of 600 ms when typing in the filter on a mid-range phone. SalesChart pulls in a 300 kB charting library and products has 10,000 items. Identify every problem and rewrite it.

tsx
import { useState } from 'react'
import { SalesChart } from './SalesChart'
import { products } from './products'

export function Dashboard() {
  const [query, setQuery] = useState('')
  const [showChart, setShowChart] = useState(false)
  const visible = products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()))

  return (
    <main>
      <img src="/hero.jpg" loading="lazy" alt="Spring sale: 30% off" className="hero" />
      <input aria-label="Filter products" value={query} onChange={(e) => setQuery(e.target.value)} />
      <button type="button" onClick={() => setShowChart(true)}>Show sales chart</button>
      {showChart && <SalesChart />}
      <ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
    </main>
  )
}
Show solution

The problems: the LCP image is lazy-loaded, has no dimensions (so it shifts everything below it when it arrives) and is a large JPEG; the chart library is in the main bundle even though most users never open it; and every keystroke synchronously re-renders up to 10,000 list items.

tsx
import { lazy, memo, Suspense, useDeferredValue, useState } from 'react'
import { products } from './products'

const SalesChart = lazy(() => import('./SalesChart').then((m) => ({ default: m.SalesChart })))

const ProductList = memo(function ProductList({ query }: { query: string }) {
  const q = query.toLowerCase()
  const visible = products.filter((p) => p.name.toLowerCase().includes(q))
  return <ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
})

export function Dashboard() {
  const [query, setQuery] = useState('')
  const [showChart, setShowChart] = useState(false)
  const deferredQuery = useDeferredValue(query)

  return (
    <main>
      <img
        src="/hero-1200.avif"
        srcSet="/hero-800.avif 800w, /hero-1200.avif 1200w"
        sizes="100vw"
        width={1200}
        height={400}
        fetchPriority="high"
        alt="Spring sale: 30% off"
        className="hero"
      />
      <input aria-label="Filter products" value={query} onChange={(e) => setQuery(e.target.value)} />
      <button type="button" onClick={() => setShowChart(true)}>Show sales chart</button>
      {showChart && (
        <Suspense fallback={<div style={{ height: 320 }}>Loading chart…</div>}>
          <SalesChart />
        </Suspense>
      )}
      <ProductList query={deferredQuery} />
    </main>
  )
}

Because this app is client-rendered, also add <link rel="preload" as="image" href="/hero-1200.avif" imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w" imagesizes="100vw" fetchpriority="high"> to index.html so the download starts before the JavaScript runs. The Suspense fallback reserves the chart's height. For the biggest INP win, virtualise the list so only on-screen rows render. Then re-measure with throttling: Lighthouse for LCP and CLS, the Performance panel while typing for INP.

Key takeaways

  • Core Web Vitals: LCP ≤ 2.5 s for loading, INP ≤ 200 ms for responsiveness, CLS ≤ 0.1 for stability, judged at the 75th percentile of real visits.
  • Use lab tools to debug and field data from real users, such as the web-vitals library, to judge.
  • For LCP, make the hero discoverable in the HTML, give it fetchpriority="high", never lazy-load it, and serve small modern formats.
  • For CLS, give media dimensions, reserve space for late content and animate with transform.
  • For INP, break up long tasks, defer non-urgent rendering and keep the DOM small.
  • Ship less JavaScript with code splitting and bundle analysis, and cache hashed assets forever.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What does Interaction to Next Paint (INP) measure?

  2. 2.The LCP element on your landing page is a hero image. Which change is most likely to improve LCP?

  3. 3.Text below an image jumps down when the image finishes loading. What is the standard fix?

  4. 4.Lighthouse gives your page 98 for performance, but field data shows a poor INP. What is the most likely explanation?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.