# Web performance

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

Frontend Developer roadmap · Production · 18 min read · https://levelcode.org/roadmaps/frontend/web-performance

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

| Metric | Measures | Good | Poor |
| --- | --- | --- | --- |
| **LCP**, Largest Contentful Paint | Loading: when the largest image or text block in the viewport is rendered | ≤ 2.5 s | > 4.0 s |
| **INP**, Interaction to Next Paint | Responsiveness: delay from a click, tap or key press until the next frame is painted | ≤ 200 ms | > 500 ms |
| **CLS**, Cumulative Layout Shift | Visual 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.

> [!IMPORTANT]
> Field data is the truth; lab data is a tool for finding causes. A perfect Lighthouse score on a fast laptop says little about a mid-range phone on a train. Always test with throttling enabled.

## 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.

> [!TIP]
> Set a performance budget, such as "under 200 kB of compressed JavaScript on the home page", and check it in CI. Regressions are much cheaper to stop than to find later.

## 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>
  )
}
```

**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.

## Quiz

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

   A. How long the first page load takes to show any content
   B. Roughly the slowest delay, across the whole visit, between a user interaction and the next frame being painted
   C. Only the delay before the very first click is handled
   D. How long JavaScript takes to download

   **Answer: B.** INP observes all clicks, taps and key presses during the visit and reports approximately the worst one, from input until the next paint. Measuring only the first interaction's delay was the old First Input Delay metric, which INP replaced.

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

   A. Add loading="lazy" to the hero image
   B. Move the image into a CSS background-image
   C. Render the image only after a useEffect runs
   D. Put the img in the initial HTML with fetchpriority="high" and an appropriately sized modern format

   **Answer: D.** The browser should discover the image early, fetch it at high priority and download as few bytes as possible. Lazy loading, CSS backgrounds and effect-driven rendering all delay discovery.

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

   A. Add width and height attributes (or a CSS aspect-ratio) so the browser reserves space
   B. Use loading="eager"
   C. Convert the image to WebP
   D. Animate the image's height from 0

   **Answer: A.** With dimensions available up front, the browser reserves the right amount of space before the file arrives, so nothing moves. Changing the format or loading priority does not reserve space.

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

   A. Lighthouse is broken and should be ignored
   B. Field data is always worse, so the result can be ignored
   C. Lighthouse measures a page load without real interactions, so it cannot observe slow event handlers that users trigger later
   D. INP only counts for pages with forms

   **Answer: C.** Lab tools simulate a load on one device; they report Total Blocking Time as a proxy but cannot measure the interactions real users perform on real hardware. Use field data to find slow interactions and the Performance panel to debug them.
