By the end you'll know
- What the Core Web Vitals measure, and the numbers Google counts as good
- How to measure a page with Lighthouse, and read where the time went
- How
next/imageturns a 2.2 MB photo into a 60 kB one - How streaming with
Suspensegets a page on screen while slow data is still coming - How to keep a big JavaScript library off the page until someone needs it
- What makes a page jump, and how to check what really caused it
Before you start
- You need: Lessons 4 and 6: the bakery's Next.js app, server and client components, and slow APIs. Node.js 20 or later, and Google Chrome (Lighthouse drives it).
- Versions: Next.js 16.4.5, Lighthouse 13.6.0 and Chart.js 4.6.1, November 2026.
- The example: Juniper & Rye, the made-up bakery, with two versions of its menu page, slow and fast. The photo is made up too, generated by a script. Every run and line of output below is real.
The short version
- Measure before you fix. Lighthouse showed one photo was 13.6 of the slow page's 15.2 seconds. Nothing else came close.
- Send images at the size they're shown.
next/imageserved a 60 kB WebP to the phone instead of the 2.2 MB original. LCP went from 15.2 s to 2.3 s. - Don't make the whole page wait for slow data. With the slow part in
Suspense, the first byte left the server after 21 ms instead of 1.6 s. - Load big libraries when they're needed. Chart.js (201 kB) moved to a button, and blocking time fell from 284 ms to 155 ms.
- Check what really moved the page. Lighthouse blamed the photo; the cause was a banner added by JavaScript.
What "fast" means
Speed isn't one number. Google's Core Web Vitals split it into three things a visitor feels, each measured at the 75th percentile of real visits:
| Measure | What it asks | Good |
|---|---|---|
| LCP, Largest Contentful Paint | When did the biggest thing on screen appear? | 2.5 s or less |
| INP, Interaction to Next Paint | When someone taps, how long until the page responds? | 200 ms or less |
| CLS, Cumulative Layout Shift | How much did things jump around while loading? | 0.1 or less |
Two more numbers help explain them:
- FCP, First Contentful Paint: when anything at all appeared.
- TBT, Total Blocking Time: how long the browser's main thread was too busy to respond during loading. INP needs a real person tapping, so lab tools like Lighthouse report TBT as a stand-in: JavaScript that blocks loading tends to block taps too.
There are two ways to measure. Lab data comes from a tool loading the page under set conditions, the same way every time, which is what you want while fixing things. Field data comes from real visitors' browsers, on their phones and networks, which is what the "good" thresholds are judged on. This lesson uses lab data; In real systems covers field data.
The two pages
The practice folder, lesson-09, has the bakery's menu twice: /slow, written the easy way, and /fast, with four fixes. Both show the same photo, today's specials (from a made-up supplier that takes 1.5 seconds), a chart of this week's bestsellers and a promotion bar.
(Your machine)
cd lesson-09
npm install
cd site
npm install
node make-hero.mjs
npm run build
npm start
- The first
npm installinstalls Lighthouse, in the lesson folder; the second installs the site's packages. node make-hero.mjscreates the photo,public/hero.jpg. It usessharp, an image library, to draw three loaves on a wooden table, with enough texture to behave like a real photo:
public/hero.jpg: 3200 x 2000, 2.2 MB
That's what a phone camera, or a photographer, typically hands you: full size, high quality, and far bigger than a web page needs.
npm run buildthennpm startserve the site on port 3009. Leave it running and use another terminal.
Both pages are dynamic, because the specials are fetched on every request:
Route (app)
┌ ○ /_not-found
├ ƒ /fast
└ ƒ /slow
Measuring
measure.mjs runs Lighthouse, Google's page checker (the same one in Chrome's developer tools), and prints the key numbers:
const { lhr } = await lighthouse(url, {
port: chrome.port,
onlyCategories: ["performance"],
throttlingMethod: "devtools",
});
- Lighthouse acts as a mid-range phone on a slow 4G connection by default. Your laptop on Wi-Fi hides most problems; that phone doesn't.
throttlingMethod: "devtools"really slows Chrome down to that phone's speed, so the numbers are measured. Lighthouse's default instead loads the page at full speed and estimates; it's faster, but in a first test here it reported the slow page's first paint (826 ms) as earlier than the server's first byte (1.6 s), which can't happen.
From the lesson folder, with the site running:
(Your machine)
node measure.mjs http://localhost:3009/slow 3
It runs Lighthouse three times, because results vary a little from run to run:
run score FCP LCP TBT CLS bytes
1 62 2.2 s 15.2 s 298 ms 0.052 2.45 MB
2 64 2.2 s 15.2 s 259 ms 0.052 2.45 MB
3 63 2.2 s 15.2 s 284 ms 0.052 2.45 MB
LCP element: body > main > img.hero
Time to first byte 1.5 s
Resource load delay 69 ms
Resource load duration 13.6 s
Element render delay 24 ms
Biggest downloads:
2240 kB image/jpeg /hero.jpg
72 kB application/javascript /_next/static/chunks/3jn_x8ihwq16e.js
69 kB application/javascript /_next/static/chunks/3sx1d5dsmlo5h.js
A score of 63 out of 100, and the page's main photo took 15.2 seconds to appear, six times the "good" limit. The bottom half says why. Lighthouse splits LCP into four parts:
- Time to first byte, 1.5 s: waiting for the server to start sending the page.
- Resource load delay, 69 ms: from then until the browser asked for the image.
- Resource load duration, 13.6 s: downloading the image. This is the problem.
- Element render delay, 24 ms: from downloaded to drawn.
Thirteen of the fifteen seconds were one 2.2 MB photo, shown 380 pixels wide on a phone. Fixing anything else first would have been wasted effort.
Fix 1: send the image at the size it's shown
The slow page uses a plain <img>:
<img className="hero" src="/hero.jpg" alt="Three loaves on a wooden table" />
Every visitor downloads the full 3200 by 2000 pixel, 2.2 MB file, whatever their screen. The fast page uses Next.js's image component:
import Image from "next/image";
import hero from "@/public/hero.jpg";
<Image
className="hero"
src={hero}
alt="Three loaves on a wooden table"
sizes="(max-width: 720px) 100vw, 720px"
preload
/>
import hero from "@/public/hero.jpg"lets Next.js read the file at build time, so it knows the width and height, and writes them into the HTML.sizestells the browser how wide the image will be shown: the full screen width on screens up to 720 pixels, otherwise 720 pixels. The browser uses it to pick the smallest file that's still sharp.preloadadds a<link rel="preload">to the page's<head>, so the browser starts downloading the image before it gets to the<img>tag. Use it for the one image that will be the largest thing on screen. (In Next.js 15 this was calledpriority; Next.js 16 renamed it.)
The <img> it produced (with the long list of sizes shortened to …):
<img alt="Three loaves on a wooden table" width="3200" height="2000" decoding="async" data-nimg="1" class="hero" style="color:transparent" sizes="(max-width: 720px) 100vw, 720px" srcSet="…" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhero.2s7z6p63two9r.jpg&w=3840&q=75"/>
The srcSet offers eight widths, from 640 to 3840 pixels, each served by /_next/image, which resizes the original and converts it to WebP (a smaller modern format) when the browser accepts it. The phone in Lighthouse picked the 750-pixel version: 60 kB instead of 2,240 kB. You'll see it in the fast page's results below.
Fix 2: don't make the whole page wait
The slow page fetches the specials at the top:
export default async function SlowMenu() {
const specials = await getSpecials(); // the whole page waits for this
Nothing can be sent until that await finishes, so the server sends nothing for 1.5 seconds: not the heading, not the photo's address. The fast page moves the specials into their own server component and wraps it in Suspense:
<h2>Today's specials</h2>
<Suspense fallback={<p>Checking today's specials…</p>}>
<Specials />
</Suspense>
Suspense marks a part of the page that can arrive later. Next.js sends everything else straight away with the fallback in its place, then streams the specials down the same connection when they're ready, and swaps them in. Timed with curl:
(Your machine)
curl -s -o /dev/null -w "/slow: HTTP %{http_code}, first byte after %{time_starttransfer}s, last byte after %{time_total}s\n" localhost:3009/slow
(and the same for /fast). time_starttransfer is when the first byte arrived; time_total is when the last did.
/slow: HTTP 200, first byte after 1.586717s, last byte after 1.588161s
/fast: HTTP 200, first byte after 0.021277s, last byte after 1.511630s
Both pages took 1.5 seconds to finish. The difference is when they started: the fast page's first byte left after 21 milliseconds, so the browser could draw the page and start the photo download while the specials were still on their way.
Fix 3: JavaScript nobody asked for
The bestsellers chart uses Chart.js, a popular charting library. On the slow page it's drawn as soon as the page loads, so every visitor downloads it, and their phone runs it, whether or not they scroll down to the chart. On the fast page, a button loads it:
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const PopularityChart = dynamic(() => import("../popularity-chart"), {
loading: () => <p>Loading chart…</p>,
});
export default function ChartButton() {
const [show, setShow] = useState(false);
return show ? <PopularityChart /> : <button onClick={() => setShow(true)}>Show the chart</button>;
}
dynamic(() => import(...))puts the chart component, and Chart.js with it, in a separate file that's downloaded only when the component is first shown.loadingis what shows while that file downloads.
chart-check.cjs (which needs Playwright, as in earlier lessons) loads both pages in a browser and lists the JavaScript files, then clicks the button:
(Your machine)
node chart-check.cjs
/slow: 7 JavaScript files, 659 kB before compression
/fast: 6 JavaScript files, 478 kB before compression
after clicking "Show the chart": 3sx1d5dsmlo5h.js, 201 kB
after clicking "Show the chart": 3vje1m0qe6ai8.js, 0 kB
The 201 kB file is Chart.js (69 kB compressed, the third-biggest download on the slow page). On the fast page, it arrives only for people who ask. The other 478 kB is mostly React and Next.js, which every Next.js page needs.
Fix 4: things that move
The promotion bar is a client component that shows itself after the page's JavaScript has run:
const [show, setShow] = useState(false);
useEffect(() => setShow(true), []);
if (!show) return null;
return <p className="promo">Order by 8pm for next-day pickup</p>;
The page appears, then a moment later the bar pushes everything down by its height. That's a layout shift, and it's how people end up tapping the wrong thing. The fast page sends the bar as plain HTML with the page, so it's there from the first paint.
Lighthouse blamed the wrong thing
The slow page's CLS was 0.052: under the 0.1 limit, but not zero. Lighthouse's report named the shifted element (<main>, moved down 44 pixels, the bar's height) and gave the cause as "Unsized image element": the <img> with no width or height. That's a classic cause, so it was worth testing rather than trusting. Two variants of the slow page, each changing one thing:
http://localhost:3009/slow-no-bar
run score FCP LCP TBT CLS bytes
1 62 2.4 s 15.3 s 293 ms 0.000 2.45 MB
http://localhost:3009/slow-sized
run score FCP LCP TBT CLS bytes
1 63 2.3 s 15.2 s 281 ms 0.052 2.45 MB
- Without the bar, CLS was 0, with the image still unsized.
- With the image sized (
width="3200" height="2000") and the bar back, CLS was 0.052 again.
The bar caused all of it. Lighthouse's "cause" is a guess based on common patterns, and here the guess was wrong. Sizing images is still right (next/image does it for you), because a browser that doesn't know an image's shape can't save room for it. In this test the missing size moved nothing, most likely because Chrome read the photo's dimensions from the start of the file before it first drew the page. Don't count on that luck with slower servers or images further down.
After
(Your machine)
node measure.mjs http://localhost:3009/fast 3
run score FCP LCP TBT CLS bytes
1 94 1.3 s 2.3 s 215 ms 0.000 0.21 MB
2 97 1.3 s 2.3 s 144 ms 0.000 0.21 MB
3 96 1.3 s 2.3 s 155 ms 0.000 0.21 MB
LCP element: body > main > img.hero
Time to first byte 21 ms
Resource load delay 643 ms
Resource load duration 1.6 s
Element render delay 17 ms
Biggest downloads:
72 kB application/javascript /_next/static/chunks/3jn_x8ihwq16e.js
60 kB image/webp /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhero.2s7z6p63tw
49 kB application/javascript /_next/static/chunks/2y66jse2o7gkn.js
LCP fell from 15.2 to 2.3 seconds, inside the 2.5-second limit, and the page downloads a twelfth as much. The breakdown shows the next target: 643 ms of "resource load delay" before the image was requested. On a slow phone, the HTML and CSS take time to arrive and be read before even a preloaded image starts. Shrinking those would be next, but at this point the page is good, and further work buys less.
TBT moved around between runs (144 to 215 ms) more than anything else. That's normal: it depends on how busy the computer running the test was. Compare medians, never a single run.
In real systems
- Collect field data. Lab tests tell you what to fix; real visitors tell you whether it worked. Vercel's Speed Insights, Google's Chrome UX Report (CrUX, shown in PageSpeed Insights for sites with enough traffic) or the
web-vitalslibrary sending numbers to your own analytics all measure real LCP, INP and CLS. - Set a budget and check it in CI. Run Lighthouse on every pull request (Lighthouse CI does this) and fail the build if LCP or page weight goes past a limit, the way Lesson 7's advisor failed on a table without security.
- Images first, usually. On most content sites, the biggest wins are image size and format. Resize at upload time if images come from people, not just in the page.
- Fonts count too.
next/fontdownloads web fonts at build time and serves them with the site, which avoids a common cause of slow text and layout shift. - Third-party scripts are the usual INP problem: chat widgets, analytics, A/B testing. Each one is someone else's JavaScript on your visitors' phones. Load them late, or not at all.
- Test on a real mid-range phone now and then. Lighthouse's emulation is good, not perfect.
Spot the problem
Work out what's wrong before you read the explanation.
"Our Lighthouse score is 98. Why do customers say it's slow?" A shop's developers check every page in Chrome's Lighthouse panel on their laptops, and scores are in the high 90s. The field data in PageSpeed Insights says LCP is 4.8 seconds for most visitors, and the product pages have a large carousel of full-size photos and a chat widget. What's wrong? They tested a different experience from the one customers get. Lighthouse on a fast developer laptop, often with the site's files already in the browser's cache, behaves nothing like a mid-range phone on mobile data. Start from the field data: it says LCP is the problem, on real devices. Then reproduce it in the lab: run Lighthouse with phone throttling, in a fresh browser profile, on a product page, and read the LCP breakdown. Likely fixes: size and convert the carousel's images and preload only the first, and load the chat widget after the page. Then check: does the field data's LCP fall over the next few weeks? (Field data is collected over 28 days, so it moves slowly.)
Keep this
| Symptom | Measure | Cause found here | Fix |
|---|---|---|---|
| Main image slow to appear | LCP | 2.2 MB photo for a 380-pixel slot | next/image with sizes, preload on the LCP image |
| Blank screen at first | FCP, time to first byte | Page waits for slow data | Suspense around the slow part |
| Busy, unresponsive page | TBT, INP | Chart library on every visit | dynamic(() => import(...)) when needed |
| Content jumps | CLS | Banner added after load | Send it with the page, or reserve its space |
| Always | Measure as a phone, run several times, fix the biggest wait first, and check the cause before you fix it | ||
Where you are now
You can measure a page as a phone would see it, read where its loading time went, and fix the four most common causes of a slow page: oversized images, waiting on slow data, too much JavaScript and content that jumps. You've also seen that a tool's explanation is a lead, not a verdict. The next lesson is about accessibility: making sure the bakery's site works for people using keyboards, screen readers and zoom.
Check yourself
The slow page's LCP was 15.2 s. Where did most of that time go?
What did next/image send to the phone instead of the 2.2 MB JPEG?
Both pages took about 1.5 s to finish sending. Why did the fast one appear sooner?
How did the fast page keep Chart.js off most visits?
Lighthouse said the layout shift was caused by the unsized image. What did testing show?
Why run Lighthouse with phone throttling instead of at your laptop's full speed?
Key terms
- Core Web Vitals: Google's three measures of how a page feels: LCP, INP and CLS.
- LCP (Largest Contentful Paint): when the biggest image or text block on screen appeared.
- INP (Interaction to Next Paint): how long the page takes to respond to a tap, click or key press.
- CLS (Cumulative Layout Shift): a score for how much visible content moved unexpectedly.
- FCP (First Contentful Paint) and TBT (Total Blocking Time): when anything first appeared, and how long the main thread was too busy to respond.
- Lab data and field data: measurements from a test tool under set conditions, and from real visitors.
- Lighthouse: Google's tool that loads a page as a simulated phone and reports these measures.
- Throttling: slowing a test's network and processor down to match a slower device.
- Responsive images (
srcsetandsizes): offering an image in several widths so the browser can pick the smallest that's sharp. - WebP: a modern image format, usually much smaller than JPEG for the same quality.
- Streaming: sending a page in parts, so the browser can start on what's ready.
- Code splitting: putting rarely used code in separate files that are downloaded only when needed.
Go deeper
- web.dev: Core Web Vitals (opens in a new tab), and how to optimize LCP (opens in a new tab), INP (opens in a new tab) and CLS (opens in a new tab).
- Next.js: the Image component (opens in a new tab), loading UI and streaming (opens in a new tab) and lazy loading (opens in a new tab).
- Chrome: Lighthouse (opens in a new tab).
- Try: remove
preloadfrom the fast page's image and measure again. How much does "resource load delay" change? - Try: delete the
sizesline, rebuild and measure. Which width does the phone download now, and what happens to LCP?