Web development basics · Lesson 9 of 11 · about 35 minutes

Speed

The bakery's menu page took 15 seconds to show its main photo on a phone. Lighthouse measured it, then four fixes took it to 2.3 seconds: a sized, modern image, streaming past slow data, loading a chart library only when asked, and sending a banner with the page instead of after it. One surprise: Lighthouse blamed the wrong element for the page jumping.

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/image turns a 2.2 MB photo into a 60 kB one
  • How streaming with Suspense gets 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/image served 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:

MeasureWhat it asksGood
LCP, Largest Contentful PaintWhen did the biggest thing on screen appear?2.5 s or less
INP, Interaction to Next PaintWhen someone taps, how long until the page responds?200 ms or less
CLS, Cumulative Layout ShiftHow 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)

BASH
cd lesson-09
npm install
cd site
npm install
node make-hero.mjs
npm run build
npm start
  • The first npm install installs Lighthouse, in the lesson folder; the second installs the site's packages.
  • node make-hero.mjs creates the photo, public/hero.jpg. It uses sharp, an image library, to draw three loaves on a wooden table, with enough texture to behave like a real photo:
TEXT
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 build then npm start serve the site on port 3009. Leave it running and use another terminal.

Both pages are dynamic, because the specials are fetched on every request:

TEXT
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:

JS
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)

BASH
node measure.mjs http://localhost:3009/slow 3

It runs Lighthouse three times, because results vary a little from run to run:

TEXT
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>:

JSX
<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:

JSX
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.
  • sizes tells 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.
  • preload adds 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 called priority; Next.js 16 renamed it.)

The <img> it produced (with the long list of sizes shortened to …):

TEXT
<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&amp;w=3840&amp;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:

JSX
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:

JSX
<h2>Today&apos;s specials</h2>
<Suspense fallback={<p>Checking today&apos;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)

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

TEXT
/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:

JSX
"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.
  • loading is 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)

BASH
node chart-check.cjs
TEXT
/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:

JSX
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:

TEXT
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)

BASH
node measure.mjs http://localhost:3009/fast 3
TEXT
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.

Where the time went before the main photo appeared, and what each fix changed. Select the diagram to enlarge it.

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-vitals library 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/font downloads 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

SymptomMeasureCause found hereFix
Main image slow to appearLCP2.2 MB photo for a 380-pixel slotnext/image with sizes, preload on the LCP image
Blank screen at firstFCP, time to first bytePage waits for slow dataSuspense around the slow part
Busy, unresponsive pageTBT, INPChart library on every visitdynamic(() => import(...)) when needed
Content jumpsCLSBanner added after loadSend it with the page, or reserve its space
AlwaysMeasure 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

  1. The slow page's LCP was 15.2 s. Where did most of that time go?

  2. What did next/image send to the phone instead of the 2.2 MB JPEG?

  3. Both pages took about 1.5 s to finish sending. Why did the fast one appear sooner?

  4. How did the fast page keep Chart.js off most visits?

  5. Lighthouse said the layout shift was caused by the unsized image. What did testing show?

  6. 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 (srcset and sizes): 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