Command Palette

Search for a command to run...

Hectal
PHASE 23Advanced ~27 min· topic 4 of 6

Topic 23.4

Core Web Vitals and Bundle Size

In one line

Core Web Vitals are three numbers measured on real users: LCP (how soon the main content appears), INP (how quickly the page responds to taps and clicks) and CLS (how much the layout jumps); you measure them with PerformanceObserver or the web-vitals library, and improve them with smaller bundles, well-loaded images and fonts, reserved space and short main-thread tasks.

Think of it like this

Ordering at a café. Three things decide whether it felt good: how long until your main dish arrived (LCP), how quickly the waiter reacted each time you waved (INP), and whether someone kept moving your plate around the table while you ate (CLS). A café can be great on average and still be awful for the one customer at the busy corner table, which is why these are measured on many real visits, not one test.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Core Web Vitals
Google's three user-experience metrics: LCP (loading), INP (responsiveness) and CLS (visual stability).
LCP
Largest Contentful Paint: when the biggest image or text block on screen was painted. Good: 2.5 s or less.
INP
Interaction to Next Paint: how long the page takes to show a response to taps, clicks and keys. Good: 200 ms or less.
CLS
Cumulative Layout Shift: a score for how much content jumps around unexpectedly. Good: 0.1 or less.
Long task
Main-thread work longer than 50 ms. The page can't respond or paint while it runs.
Field vs lab data
Field: measured on real users' devices. Lab: measured in a controlled test such as Lighthouse.
75th percentile
The value that 75% of visits are better than. Core Web Vitals are judged at this point.
PerformanceObserver
A browser API that tells your code about performance entries such as paints, layout shifts and long tasks.
Bundle visualiser
A tool that draws each chunk as boxes sized by the modules inside it, so heavy libraries stand out.
Performance budget
An agreed limit, such as a maximum bundle size or LCP, checked automatically.

Step by step

01Know what each metric waits for

In a client-rendered app the LCP chain is long. Each arrow is a place to save time: smaller JS, preloading the hero image, rendering on the server.

Know what each metric waits fordiagram
Rendering diagram…

02Measure with the web-vitals library

Install web-vitals and report each metric once it's final. sendBeacon queues the request so it's sent even if the user is closing the tab. Report from production builds only: development builds are slower and StrictMode doubles work.

src/vitals.jswhole filejsx
import { onCLS, onINP, onLCP } from "web-vitals";

function send(metric) {
  // metric: { name: "LCP", value: 1830.4, rating: "good", delta, id, ... }
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    page: location.pathname,
  });
  if (!navigator.sendBeacon("/analytics/vitals", body)) {
    fetch("/analytics/vitals", { body, method: "POST", keepalive: true });
  }
}

onCLS(send);
onINP(send);
onLCP(send);

03Or in Next.js

Next.js has a hook that receives the same metrics. Put it in a small client component rendered from the root layout.

app/web-vitals.tsxwhole filetsx
"use client";
import { useReportWebVitals } from "next/web-vitals";

export function WebVitals() {
  useReportWebVitals((metric) => {
    navigator.sendBeacon("/analytics/vitals", JSON.stringify(metric));
  });
  return null;
}

04Find what's heavy in the bundle

Add a visualiser plugin to the build and open the report it writes. Check that the plugin supports your Vite version. Look for big boxes you don't recognise, the same library twice, and code that only one rarely used page needs.

vite.config.js (example)whole filejsx
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [react(), visualizer({ filename: "stats.html", gzipSize: true })],
});

05Load the hero image and fonts properly

The LCP image gets real dimensions, a high fetch priority and no lazy loading. Images further down get loading="lazy". One font file used above the fold is preloaded.

Hero.jsxwhole filejsx
import { preload } from "react-dom";

export function Hero() {
  preload("/fonts/inter-latin.woff2", { as: "font", type: "font/woff2", crossOrigin: "anonymous" });
  return (
    <section>
      <img
        src="/img/mangoes-800.avif"
        srcSet="/img/mangoes-400.avif 400w, /img/mangoes-800.avif 800w, /img/mangoes-1600.avif 1600w"
        sizes="(max-width: 600px) 100vw, 800px"
        width="800"
        height="450"
        fetchPriority="high"
        alt="A crate of ripe Alphonso mangoes"
      />
      <h1>Fresh mangoes</h1>
    </section>
  );
}

/* index.css */
/* @font-face { font-family: Inter; src: url(/fonts/inter-latin.woff2) format("woff2"); font-display: swap; } */

06Fix, then check field data again

Lab tools show whether a change helped on your machine; field data, a few days after release, shows whether it helped users. Compare the 75th percentile before and after, per page type and per device type.

Try it yourself

  1. 1

    Make the shift worse, then smaller

    In the first example, change the banner's height to 120px. Log seen.cls.toFixed(3) and compare it with the 36 px version on your own screen (the exact number depends on your screen size, which is why we don't print it in the checked output).

  2. 2

    Chunk size matters

    In the INP example, change the chunked loop to 3 rounds of busy(60). Predict the long-task count for the chunked click, then run it. What chunk size keeps every task under 50 ms?

  3. 3

    Try a transition instead

    Topic 16.7 showed useTransition. Would wrapping setStatus("done (slow)") in startTransition fix the slow handler here? Think about where the 150 ms is spent (your loop, not React's render) before you try it.

Code & diagrams

Watch LCP and a layout shift happen New tab

The banner pushes the heading, text and button down 300 ms after load: a layout shift without user input, so it counts towards CLS. Chromium-based browsers report `largest-contentful-paint` and `layout-shift`; Firefox and Safari support fewer entry types, so check `PerformanceObserver.supportedEntryTypes` before observing in production code (or let `web-vitals` do it).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

LCP entry reported: true
layout shift reported: true
shift without user input (counts for CLS): true
CLS above zero: true
Reserve the space: no shift New tab

Same banner, same timing, zero layout shift: the space was reserved from the first paint. The same idea fixes images (`width`/`height` or `aspect-ratio`) and skeleton screens (make the skeleton the same size as the content it stands for).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

layout shift reported: false
CLS above zero: false
INP: one long task vs work that yields New tab

The slow handler never lets the browser paint "working": the user sees nothing for 150 ms, then the result. The chunked version does the same total work but returns to the browser every 15 ms, so "working" appears at once and other input is handled between chunks. Click the slow button yourself: the button even fails to show its pressed state. Chromium also offers `scheduler.yield()`, which resumes your work ahead of other queued tasks.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

long tasks during the slow click: 1
long tasks during the chunked click: 0
Status: done (chunked)

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

An image without dimensions

Render <img src={product.photo} alt="" /> with no width/height and no CSS size, above the product description.

terminal
$ Lighthouse / PerformanceObserver
── what you'll see ──
Layout shift reported when the image loads; Lighthouse: "Image elements do not have explicit width and height"

Break #2

Lazy-loading the hero image

Add loading="lazy" to every <img> in the app, including the large hero image at the top of the home page.

terminal
$ Field data after release
── what you'll see ──
LCP p75 gets worse: the hero image starts downloading only after layout confirms it's in the viewport

Myth vs fact

Myth

A Lighthouse score of 100 means users have good Core Web Vitals.

Fact

Lighthouse is one lab run on one simulated device. Only field data at the 75th percentile tells you what users experience, and INP can't be measured without real interactions.

Myth

React apps are slow because React is big.

Fact

React and React DOM are about 60–70 kB gzipped. Slow apps usually ship far more of their own and third-party code, render too much, or load images and fonts badly.

Myth

CLS only happens while the page loads.

Fact

CLS covers the whole visit: content inserted after a fetch, an infinite list, or a toast pushing content all count unless they follow a user input within 500 ms.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The long-animation-frame (LoAF) entry type is the successor to longtask for diagnosing INP: it reports the whole frame, including rendering, and attributes time to the scripts that ran (entry.scripts with source URLs). web-vitals/attribution uses it to tell you which element and which script made an interaction slow.

  • ▸

    React 18+ yields to the browser every few milliseconds during concurrent (transition) renders, so a big re-render inside startTransition doesn't create one long task. It can't split your own synchronous loops or event handler code: those need chunking, a Web Worker, or less work.

  • ▸

    LCP stops being updated after the first user input, and it doesn't count elements removed from the DOM. A client-rendered skeleton followed by real content often makes the real content the LCP element, so faster data loading (or SSR with streaming, Phase 20) moves LCP directly.

  • ▸

    Third-party scripts (tag managers, chat widgets, A/B testing) are frequent INP and LCP problems. Load them with async/defer or after the page is interactive, and measure their cost with LoAF script attribution before adding them.

Remember this

  1. 1

    LCP, Largest Contentful Paint: the time from the start of navigation until the largest image or text block in the viewport is painted. It's a stand-in for "the main content is visible". Good is 2.5 seconds or less; poor is over 4 seconds. In a client-rendered React app, LCP waits for HTML, then the JavaScript bundle to download, parse and run, then React to render, then often a data fetch and an image. Every step in that chain adds up, which is why bundle size and SSR (Phase 20) matter so much for LCP.

  2. 2

    INP, Interaction to Next Paint: for clicks, taps and key presses, the time from the input until the browser paints the next frame, reported as roughly the worst interaction of the visit. Good is 200 ms or less; poor is over 500 ms. It replaced FID (First Input Delay) as a Core Web Vital in March 2024, because FID measured only the first input's waiting time. INP suffers from long tasks (main-thread work over 50 ms): a slow event handler, a huge re-render, a big list, third-party scripts. React tools that help: useTransition and useDeferredValue (Topic 16.7), memoisation (Topics 16.3–16.5), virtualised lists (Topic 16.9), and breaking long work into chunks that yield to the browser.

  3. 3

    CLS, Cumulative Layout Shift: how much visible content moves unexpectedly while the page is open. Each layout shift scores (fraction of the screen affected × distance moved); shifts within 500 ms of a user input don't count, and CLS reports the worst burst ("session window") of shifts. Good is 0.1 or less; poor is over 0.25. Typical React causes: images without width/height, a banner or ad inserted above content after data loads, a loading spinner replaced by taller content, and web fonts that swap to a different size. The fix is almost always to reserve the space before the content arrives.

  4. 4

    Measure in the field, debug in the lab. Field data (also called RUM, real-user monitoring) comes from your actual users' devices and networks; Google's search ranking uses the 75th percentile of field data (the value 75% of visits are better than). Lab data comes from a test run, like Lighthouse in Chrome DevTools: repeatable and good for debugging, but one device and one network. The browser exposes the raw entries through **PerformanceObserver** (entry types largest-contentful-paint, layout-shift, event, longtask, long-animation-frame). The small **web-vitals** library from Google turns them into the official metrics with the right edge cases (onLCP, onINP, onCLS, plus onFCP and onTTFB); each callback gets a metric with name, value, rating ("good", "needs-improvement" or "poor"), delta and id. Send them to your analytics with navigator.sendBeacon, which survives the page closing.

  5. 5

    Bundle size feeds every metric. JavaScript is the most expensive byte on the web: it must be downloaded, parsed, compiled and executed on the main thread, and nothing interactive works until it has run. Read the build output (Topic 23.1), heed Vite's warning about chunks over 500 kB, and use a bundle visualiser (a treemap of what's in each chunk) to find heavy libraries: a full date library for one format call, an icon pack imported whole, two versions of the same package. Then remove, replace with a smaller library or a native API (Intl.DateTimeFormat, structuredClone), or split with lazy (Topic 16.8). A performance budget (for example: "main chunk under 170 kB gzipped") checked in CI stops slow growth.

  6. 6

    Images and fonts are usually the LCP element. Images: always set width and height (or CSS aspect-ratio) so the browser reserves space; use modern formats (AVIF, WebP) and srcset/sizes so phones get small files; add loading="lazy" to images below the fold but never to the LCP image, and give the LCP image fetchPriority="high". Fonts: self-host WOFF2 files, preload the one or two used above the fold, use font-display: swap so text shows immediately, and adjust the fallback font's metrics (size-adjust) so the swap doesn't shift layout. In React 19, preload() from react-dom adds a <link rel="preload"> from any component (Next.js's next/image and next/font do all of this for you).

Explain it without notes

01

What do LCP, INP and CLS measure, and what are the "good" thresholds?

02

What's the difference between field and lab data, and which one do Core Web Vitals use?

03

Give three common causes of poor INP in a React app and how to fix each.

04

How would you find out why a React app's bundle is large, and what would you do about it?

Practice

01

Write a function rate(name, value) that returns "good", "needs-improvement" or "poor" for LCP (ms), INP (ms) and CLS using the official thresholds, and test it with LCP 2400, INP 350 and CLS 0.3.

02

A product card shows a 4:3 photo that loads after the card renders, and the grid jumps. Write the card so it never shifts.

Trade-offs

  • ↔

    Collecting field metrics for every visit gives precise data but costs requests and storage; sampling (for example 10% of sessions) is usually enough to see the 75th percentile.

  • ↔

    Inlining critical CSS or preloading many files can improve LCP but delays everything else if overused; preload only what the first screen needs.

  • ↔

    Chunking work keeps the page responsive but makes the total work take slightly longer; that's almost always the right trade for interactions.

Done when you can

  • I can explain LCP, INP and CLS and their good thresholds.

  • I can observe LCP, layout shifts and long tasks with PerformanceObserver.

  • I can report real-user metrics with the web-vitals library and sendBeacon.

  • I can find heavy modules in a bundle and reduce them.

  • I load the LCP image and fonts correctly and reserve space for late content.

  • I keep event handlers short or chunked so interactions paint quickly.