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.
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.
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.
"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.
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.
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
Make the shift worse, then smaller
In the first example, change the banner's height to
120px. Logseen.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
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
Try a transition instead
Topic 16.7 showed
useTransition. Would wrappingsetStatus("done (slow)")instartTransitionfix the slow handler here? Think about where the 150 ms is spent (your loop, not React's render) before you try it.
Code & diagrams
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
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: trueSame 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
Console
Nothing logged yet.Expected console output
layout shift reported: false
CLS above zero: falseThe 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
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.
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.
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 tolongtaskfor diagnosing INP: it reports the whole frame, including rendering, and attributes time to the scripts that ran (entry.scriptswith source URLs).web-vitals/attributionuses 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
startTransitiondoesn'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/deferor after the page is interactive, and measure their cost with LoAF script attribution before adding them.
Remember this
- 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
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:
useTransitionanduseDeferredValue(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
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
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 typeslargest-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, plusonFCPandonTTFB); each callback gets a metric withname,value,rating("good","needs-improvement"or"poor"),deltaandid. Send them to your analytics withnavigator.sendBeacon, which survives the page closing. - 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 withlazy(Topic 16.8). A performance budget (for example: "main chunk under 170 kB gzipped") checked in CI stops slow growth. - 6
Images and fonts are usually the LCP element. Images: always set
widthandheight(or CSSaspect-ratio) so the browser reserves space; use modern formats (AVIF, WebP) andsrcset/sizesso phones get small files; addloading="lazy"to images below the fold but never to the LCP image, and give the LCP imagefetchPriority="high". Fonts: self-host WOFF2 files, preload the one or two used above the fold, usefont-display: swapso text shows immediately, and adjust the fallback font's metrics (size-adjust) so the swap doesn't shift layout. In React 19,preload()fromreact-domadds a<link rel="preload">from any component (Next.js'snext/imageandnext/fontdo all of this for you).
Explain it without notes
What do LCP, INP and CLS measure, and what are the "good" thresholds?
What's the difference between field and lab data, and which one do Core Web Vitals use?
Give three common causes of poor INP in a React app and how to fix each.
How would you find out why a React app's bundle is large, and what would you do about it?
Practice
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.
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-vitalslibrary andsendBeacon.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.