completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Code splitting

Split JavaScript by route and by component with import(). Measured on a phone: what it saves, the waterfalls it adds, and how preload and prefetch fix them.

By the end, you can
  • 01
    Split where it paysSplit an app by page and by heavy component with import() and React's lazy, and check which files each page loads.
  • 02
    Stop files waiting on each otherSpot a chain of chunks in a trace, and name a page's files up front with modulepreload.
  • 03
    Have the next chunk readyFetch the file the next tap needs before the tap, and keep chunks big enough to pack well.

The tea list arrives with the whole shop inside

Meera has new plans for Leaf and Kettle. The shop's app gets three more pages: a cart, a checkout, and an account page for each visitor. And reviews get an editor, so people can write more than one line. It has bold, italic, links and lists.

Ravi builds it all into the tea list's app. It's still one app, and the address picks the page. It's still one file, too: app.js. Sent with Brotli, it's 226.1 KB. That's under the 250 KB budget from lesson 22.1.

Then Asha opens the tea list on the bus. She wants to see the teas. Her phone downloads all of app.js, and runs it. That includes the cart, the checkout and her account page. It includes the review editor, too. Asha has never written a review.

So Ravi asks a fair question. Why should Asha's phone fetch pages she isn't on? Could the tea list send only its own code, and the rest later? He tries it. Here is the tea list on Asha's phone, built both ways:

The tea list on Asha's phone, built as one file and split. 2.5 seconds after she opened it: one file, teas not yet, ready: 2,814 ms; split, teas showing, ready: 2,274 ms. Half a second after she tapped Write a review: one file, editor showing; the editor came 102 ms after the tap; split, still loading; the editor came 980 ms after the tap.2.5 s after Asha opens the page0.5 s after she taps Write a reviewOne fileOnly the shop's name at the top; the rest is whiteReady: 2,814 msteas not yetSplitThe tea list: the heading, the search box, the Write a review button and the first teasReady: 2,274 msteas showingOne fileThe tea list with the review editor open: a toolbar and a box that asks Which tea did you try?Editor: 102 mseditor showingSplitThe tea list with a line that says Loading the editor… where the editor will goEditor: 980 msstill loading
Real frames from Asha's phone, from the visits in the middle of three. Split, the teas came sooner. But the editor came later, because its chunk only loads after the tap.

On the left is the page 2.5 seconds after Asha opened it. The split tea list shows the teas. The one-file tea list still shows only the shop's name. On the right is the page half a second after she taps Write a review. With one file, the editor is open. Split, it's still loading.

"Asha's phone" is the stand-in from lesson 22.1: Chrome on a phone's screen, DevTools' Slow 4G network, and a CPU 4 times slower. Here are the numbers behind the picture:

The tea list on Asha's phone, one file and splitPop out in the code editor (opens in a new tab)JavaScript
// Asha's phone: the tea list's first JavaScript (KB, sent with Brotli), when it could// answer a tap (ms), and how long the editor took to appear after she tapped (ms).const oneFile = { sent: 226.1, answers: 2814, editor: 102 };const split = { sent: 111.1, answers: 2274, editor: 980 }; console.log(`One file: ${oneFile.sent} KB, answers at ${oneFile.answers} ms, editor after ${oneFile.editor} ms.`);console.log(`Split: ${split.sent} KB, answers at ${split.answers} ms, editor after ${split.editor} ms.`);
  • Lines 1 and 2 are comments. A KB is 1,024 bytes. Sent with Brotli means the bytes that travel, after the server packs the file. To answer a tap is as in lesson 22.1: the later of two moments, the teas appearing and the last long task ending. A long task keeps the phone busy for more than 50 ms.
  • Line 3 is the one-file build. Line 4 is the split build.
  • Lines 6 and 7 print One file: 226.1 KB, answers at 2814 ms, editor after 102 ms. and Split: 111.1 KB, answers at 2274 ms, editor after 980 ms.

Split, the tea list sent half the JavaScript. It answered 540 ms sooner. But the editor took almost a second to appear. Why it matters: splitting doesn't make code free. It moves the cost to the moment the code is needed. This lesson is about choosing that moment well.

Real-life analogyA meal served course by course

A good restaurant doesn't bring the soup, the main course, the cake and the coffee all at once. The table would be full, and the coffee would go cold. Each course comes when you're ready for it. The cake still has to come from the kitchen, so you wait a little for it.

In real life: The whole meal on the table at once
In web performance: One app.js, with every page and the editor
In real life: The course you're eating now
In web performance: The tea list's own code, loaded first
In real life: The cake, brought out later
In web performance: The editor's file, loaded after the tap
In real life: Waiting a moment for the cake
In web performance: The wait after Asha taps Write a review
The main idea

Code splitting cuts one big JavaScript file into smaller files, called chunks. A page loads the chunks it needs now. The rest load later, when they're needed. You mark where to cut with import(). The cost doesn't vanish: it moves to the moment a chunk is needed.

The system design lesson Code splitting and bundling shows the idea without a stopwatch. Here, you measure it. The lesson follows Ravi, one question at a time:

  • How does each page get its own file? Splitting by route.
  • Why did his first split get slower? Request waterfalls.
  • How does the editor wait for a tap? Splitting by component.
  • Can the editor be ready before the tap? Prefetching the next chunk.
  • How many files is too many? Chunk size and count.

One file per page: splitting by route

First, how does one app have four pages? Ravi's app.jsx reads the address, and draws the page that matches it:

app.jsx, with every page in one fileJSX
import { createRoot } from "react-dom/client";import TeaList from "./pages/teas.jsx";import Cart from "./pages/cart.jsx";import Checkout from "./pages/checkout.jsx";import Account from "./pages/account.jsx"; const PAGES = { "/": TeaList, "/cart": Cart, "/checkout": Checkout, "/account": Account };const Page = PAGES[location.pathname] ?? TeaList; createRoot(document.getElementById("root")).render(<Page />);
  • Lines 2 to 5 import the four pages. These are static imports: plain import lines that the bundler reads before anything runs.
  • Line 7 pairs each address with its page. Line 8 picks the page for this address, location.pathname. Any other address gets the tea list.
  • Line 10 draws that page.

A page of an app with its own address is often called a route. Here, all four routes come in app.js, wherever Asha starts. A bundler follows every import line, so lines 2 to 5 pull all four pages into one file.

To split, Ravi changes those lines. Now each page loads only when its address is opened:

app.jsx, split by routeJSX
import { Suspense, lazy } from "react";import { createRoot } from "react-dom/client"; const PAGES = {  "/": lazy(() => import("./pages/teas.jsx")),  "/cart": lazy(() => import("./pages/cart.jsx")),  "/checkout": lazy(() => import("./pages/checkout.jsx")),  "/account": lazy(() => import("./pages/account.jsx")),};const Page = PAGES[location.pathname] ?? PAGES["/"]; createRoot(document.getElementById("root")).render(  <Suspense fallback={<p className="loading">Loading…</p>}>    <Page />  </Suspense>,);
  • Line 5 holds import("./pages/teas.jsx"). This is a dynamic import. It looks like a function call. It loads a file while the code runs, not before, and gives back a promise of the file's exports.
  • lazy wraps it. React's docs say it "lets you defer loading component's code until it is rendered for the first time." So React only calls import() when it first draws that page.
  • Lines 6 to 8 do the same for the other three pages.
  • Lines 13 to 15 wrap the page in Suspense. Its fallback is what React shows while the page's code loads: a line that says "Loading…".

A promise is a value that isn't ready yet, like the ticket you get for your coat at a cloakroom. The JavaScript standard spells out what import() does. It starts loading the file, and hands back the promise at once. The file arrives. Then the files it imports arrive. Then it runs. Only then does the promise get the file's exports. The JavaScript lesson Dynamic import walks through it.

The bundler has to agree to split, too. esbuild only does it when asked:

Ravi's build, splitShell
npx esbuild src/app.jsx --bundle --minify --outdir=dist \  --format=esm --splitting
  • Line 1 is Ravi's build from lesson 22.2: bundle src/app.jsx and everything it imports, and minify it. --outdir=dist names a folder, because there will be several files.
  • Line 2 asks for ES modules, files that load each other with import lines (--format=esm). And it asks for splitting. esbuild's docs say splitting "only works with the esm output format".

Here is what esbuild wrote, with each file's size sent with Brotli:

The files esbuild wrote, split by routeText
dist/app.js                 56.0 KB  React DOM and the address check
dist/chunk-RJHX4K5A.js       3.1 KB  React, which every page uses
dist/teas-WE6RLSE5.js      101.4 KB  the tea list
dist/cart-ZHH35YB6.js       24.7 KB  the cart
dist/checkout-YL7V4CSU.js   24.8 KB  the checkout
dist/account-DACWXPLP.js    24.0 KB  the account page

Each import() became a file of its own. The bundler's output files are called chunks, as in the JavaScript lesson Bundlers & build tools. Each page's chunk is named after its source file, plus a hash: a short code made from the file's content. The system design lesson explains why the hash is there.

One more file appeared: chunk-RJHX4K5A.js. It holds React, which app.js and all four pages use. esbuild moves code that several chunks share into a shared chunk of its own. Its docs say the name part of a shared chunk is, for now, always chunk.

Real-life analogyPacking a school bag for today

One pupil packs every book, every day, just in case. Her bag is heavy. Another checks the timetable and packs only today's books. The pencil case goes in every day, because every lesson needs it.

In real life: Every book, every day
In web performance: One app.js with all four pages
In real life: Today's timetable
In web performance: The address Asha opens
In real life: Today's books
In web performance: That page's chunk
In real life: The pencil case, packed every day
In web performance: The shared chunk with React

On the tea list, Asha's phone now loads three files: app.js, the shared chunk and the tea list's chunk. The cart, checkout and account chunks wait for their pages. The course loaded the split tea list on Asha's phone:

Split by route, against one filePop out in the code editor (opens in a new tab)JavaScript
// The tea list's first JavaScript, sent with Brotli (KB), and when it could answer a tap (ms).const oneFile = { sent: 226.1, answers: 2814 };const byRoute = { sent: 160.6, answers: 3596 }; console.log(`${(oneFile.sent - byRoute.sent).toFixed(1)} KB less to send`);console.log(`${byRoute.answers - oneFile.answers} ms later to answer`);
  • Line 2 is one file. Line 3 is split by route.
  • Lines 5 and 6 print 65.5 KB less to send and 782 ms later to answer.

Less to send, and yet later to answer? That's not what Ravi expected. The next section finds where the time went.

Chunks that wait for chunks: request waterfalls

The course recorded a trace of each visit. A trace is Chrome's own record of what it did, and when. Here is when Chrome asked for each of the tea list's files, and when each one's last byte came:

Split by route: the tea list's three requestsPop out in the code editor (opens in a new tab)JavaScript
// Split by route: when Chrome asked for each file, and when its last byte came, in ms.const requests = [  ["app.js", 606, 1498],  ["chunk-RJHX4K5A.js", 1501, 2099],  ["teas-WE6RLSE5.js", 2179, 3339],]; for (const [file, asked, arrived] of requests) console.log(`${file}: asked at ${asked}, arrived at ${arrived}`);
  • Lines 3 to 5 hold three files. Each has the moment Chrome asked for it, and the moment its last byte came, in ms.
  • Line 8 prints one line for each: app.js: asked at 606, arrived at 1498, then chunk-RJHX4K5A.js: asked at 1501, arrived at 2099 and teas-WE6RLSE5.js: asked at 2179, arrived at 3339.

Look at the times Chrome asked. It asked for the shared chunk 3 ms after app.js arrived. It asked for the tea list's chunk 80 ms after the shared chunk arrived. Each file waited for the one before it.

Why? The browser only learns what a file imports by reading that file.

  • app.js names the shared chunk on an import line. Chrome can't see that line until app.js arrives.
  • app.js names the tea list's chunk in import(). That only runs when app.js runs. And app.js can't run until the shared chunk it imports has arrived.

This is a request waterfall: a chain of requests, where each one is found only after the one before it. DevTools' Slow 4G adds 562.5 ms of delay to every request. So each step of the chain cost more than half a second, before a single byte arrived.

Real-life analogyA recipe that sends you to another page

You start a recipe. Step 3 says: make the sauce on page 40. You turn to page 40. It says: first, make the stock on page 72. You find each page only by reading the one before. If the first page listed all three, you could start them all at once.

In real life: Page 40, named on the first page
In web performance: The shared chunk, named inside app.js
In real life: Page 72, named on page 40
In web performance: The tea list's chunk, found when app.js runs
In real life: Reading one page before you find the next
In web performance: One request after another
In real life: A list of every page, on the first page
In web performance: modulepreload links in the HTML

The fix is a hint from lesson 16.4: modulepreload. It tells the browser about a module before anything imports it. MDN says the browser will "fetch a module script, parse and compile it, and store it in the document's module map for later execution." The module map is the page's list of modules it has loaded. The module waits there until something imports it.

Ravi's build writes the tea list's files into its HTML:

The tea list's script lines, with modulepreloadHTML
<link rel="modulepreload" href="chunk-RJHX4K5A.js"><link rel="modulepreload" href="teas-WE6RLSE5.js"><script type="module" src="app.js"></script>
  • Lines 1 and 2 name the shared chunk and the tea list's chunk.
  • Line 3 is app.js itself. The browser finds it right here, so it needs no hint.

How does the build know the names? esbuild lists every chunk's imports in its metafile, the file of facts from lesson 22.2. Vite knows the same facts, and writes some links for you. Its guide says it adds modulepreload links "for entry chunks and their direct imports" to the built HTML. And when an import() runs, it fetches that chunk and the chunks it imports at the same time. But a chunk loaded with import(), like the tea list's, gets no link in the HTML. A server or a framework that knows the address can name it in the HTML, as Ravi's build does.

The course loaded the tea list again, with the hints:

When the tea list could answer, three waysPop out in the code editor (opens in a new tab)JavaScript
// When the tea list could answer a tap, in ms.const answers = { "one file": 2814, "by route": 3596, "by route, with modulepreload": 2372 }; for (const [build, at] of Object.entries(answers)) console.log(`${build}: ${at}`);
  • Line 2 holds when each build could answer a tap, in ms.
  • Line 4 prints one file: 2814, by route: 3596 and by route, with modulepreload: 2372.

With the hints, Chrome asked for all three files within 3 ms of each other. The chain was gone. The split tea list now answered 442 ms sooner than one file. Here are both visits, request by request:

When Chrome asked for each of the tea list's three files, app.js, chunk-RJHX4K5A.js, teas-WE6RLSE5.js, and when each arrived, from the trace. By route: each bar starts after the one before ends; the page could answer at 3,596 ms. By route, with modulepreload: all three bars start together; the page could answer at 2,372 ms.By routeapp.jschunk-RJHX4K5A.jsteas-WE6RLSE5.jsanswers at 3,596 msBy route, with modulepreloadapp.jschunk-RJHX4K5A.jsteas-WE6RLSE5.jsanswers at 2,372 ms0 s1 s2 s3 s4 s
Each bar runs from the moment Chrome asked for a file to its last byte. Without hints, each file was found only after the one before. With modulepreload, all three started together. The dashed line is when the page could answer a tap.

Why it matters: every link in a chain costs a whole trip. Name the files a page needs up front. Try the three visits yourself. The third one is from the next section:

Playground: one file after another, or all at once
The page's script lines

<script type="module" src="app.js"></script>

  1. app.jsasked at 606 ms, last byte at 1,498 ms
  2. chunk-RJHX4K5A.jsasked at 1,501 ms, last byte at 2,099 ms
  3. teas-WE6RLSE5.jsasked at 2,179 ms, last byte at 3,339 ms
  4. The tea list could answer a tapat 3,596 ms
Try it yourself
Each request, from the trace

Pick a visit. The box shows the lines the page's HTML had for its JavaScript. Each row is one of the tea list's files: when Chrome asked for it, and when its last byte came, in ms after the visit started.

From the visit in the middle of three, in Chrome 157 on a phone's screen, DevTools' Slow 4G, a CPU 4 times slower.

The review editor waits for a tap: splitting by component

Split by route, the tea list's own chunk is still 101.4 KB sent. Ravi opens its treemap, the picture of a file's parts from lesson 22.2. The biggest part isn't the tea list at all. It's Quill, the rich-text editor behind Meera's review editor: 120.6 KB as built, before its helpers. Most visitors never write a review.

A page is one place to cut. A component is another: one part of a page, like the editor. Here is how the button and the editor are joined now:

write-review.jsx, with the editor in the page's chunkJSX
import { useState } from "react";import ReviewEditor from "./review-editor.jsx"; export function WriteReview() {  const [open, setOpen] = useState(false);  if (!open) return <button id="write" type="button" onClick={() => setOpen(true)}>Write a review</button>;  return <ReviewEditor />;}
  • Line 2 imports the editor with a static import, so the editor's code comes with the page.
  • Line 5 keeps track of whether Asha has tapped.
  • Line 6 shows the button until she taps it. Line 7 shows the editor after.

Ravi changes two things:

write-review.jsx, with the editor loaded on the tapJSX
import { Suspense, lazy, useState } from "react"; const ReviewEditor = lazy(() => import("./review-editor.jsx")); export function WriteReview() {  const [open, setOpen] = useState(false);  if (!open) return <button id="write" type="button" onClick={() => setOpen(true)}>Write a review</button>;  return (    <Suspense fallback={<p className="loading">Loading the editor…</p>}>      <ReviewEditor />    </Suspense>  );}
  • Line 3 loads the editor with lazy and import(), as the pages were.
  • Lines 9 to 11 wrap the editor in Suspense. Until its code arrives, Asha sees "Loading the editor…".

React's docs add two rules. Call lazy at the top level of the file, as line 3 does, never inside a component. Inside, each render makes a new component, and its state is lost. And the file must export its component as the default export, as review-editor.jsx does.

Real-life analogyThe folding bed in the cupboard

A family keeps a folding bed in the cupboard, not in the living room. Most days nobody needs it, and the room has space. When a guest comes, someone fetches it and unfolds it. The guest waits a few minutes, but only on the night they stay.

In real life: The living room
In web performance: The tea list's first chunks
In real life: The folding bed
In web performance: The editor's chunk
In real life: A guest arriving
In web performance: Asha tapping Write a review
In real life: Unfolding the bed
In web performance: Fetching and running the chunk after the tap

Ravi builds again and checks the files. The tea list now loads four. One is a new shared chunk, chunk-XHXVHECQ.js. It holds 147 lodash-es files: 21.4 KB as built. The search box needs only debounce, which is 14 of them. Quill needs the rest.

Why are Quill's helpers in a chunk the tea list loads? The search box and Quill both import from "lodash-es". That's lodash-es's barrel file, from lesson 22.3: a file that only passes on exports from other files. esbuild put every lodash-es file either of them used into one chunk that both share. So Ravi takes the side door from lesson 22.3:

search.jsx's import of debounce, before and afterJavaScript
import { debounce } from "lodash-es";  // through the barrel
import debounce from "lodash-es/debounce.js";  // the side door

Line 1 is the old line, through the barrel. Line 2 goes straight to debounce.js. Now the shared chunk holds just debounce's 14 files: 2.3 KB as built. Quill's other lodash-es files moved into the editor's chunk. Here is the result on Asha's phone:

Split by component too, against by routePop out in the code editor (opens in a new tab)JavaScript
// The tea list's first JavaScript, sent with Brotli (KB), and when it could answer a tap (ms).const byRoute = { sent: 160.6, answers: 2372 };const byComponent = { sent: 111.1, answers: 2274 }; console.log(`${(byRoute.sent - byComponent.sent).toFixed(1)} KB less, answers ${byRoute.answers - byComponent.answers} ms sooner`);
  • Line 2 is split by route, with modulepreload. Line 3 splits out the editor too, also with modulepreload.
  • Line 5 prints 49.5 KB less, answers 98 ms sooner.

The editor's chunk, 50.5 KB sent, now loads only after the tap. Why it matters: one heavy component can weigh more than the page around it. Find it in the treemap, and load it when it's used. Compare the three builds file by file:

Playground: what the tea list loads, and what waits
Loads with the tea list
  1. app.js226.1 KB: React DOM and the address check
  2. The tea list loads226.1 KB in 1 file
Waits until it's needed

Nothing. Every page and the editor came in app.js.

Try it yourself
The same app, built three ways

Pick a build. The first list is every file the tea list loads at once, sent with Brotli. The second is what waits for another page or a tap.

Built with esbuild 0.28.2. Sizes are the files esbuild wrote, packed with Brotli.

There's a catch, and you saw it in the first picture. When Asha tapped Write a review, the editor took 980 ms to appear. The next section looks at that wait.

Fetching the next chunk before the tap

Here is what happened after the tap. Within 17 ms, React showed "Loading the editor…". Then the phone asked for the editor's chunk, waited for it on Slow 4G, and ran it. Only then did the editor appear.

But Asha didn't tap at once. She tapped about 10 seconds after she opened the page. For most of that time, the network sat idle. Could the chunk come then?

Real-life analogyPutting the kettle on when you hear the car

A friend is coming for tea. You hear her car outside, so you put the kettle on before she rings the bell. By the time she sits down, the water is hot. If she only stops to drop off a parcel, you boiled the water for nothing.

In real life: Hearing the car outside
In web performance: The page has finished loading
In real life: Putting the kettle on
In web performance: Fetching the editor's chunk before the tap
In real life: Your friend asking for tea
In web performance: Asha tapping Write a review
In real life: Boiling water for nothing
In web performance: Bytes for a visitor who never taps

Lesson 16.4 showed two hints that can do this. prefetch fetches a file for later, into the HTTP cache, the browser's store of files it has downloaded. modulepreload fetches a module and gets it ready to run. Ravi's build writes a small script into the page. Once the page has loaded, it adds one hint for the editor's chunk:

The page's hint for the editorHTML
<script>  addEventListener("load", () => {    const link = document.createElement("link");    link.rel = "prefetch";    link.href = "review-editor-KV4BS27E.js";    document.head.append(link);  });</script>
  • Line 2 waits for the load event: the moment the page and its scripts have loaded.
  • Lines 3 to 5 make a <link> element. rel says which hint. href names the editor's chunk.
  • Line 6 adds it to the page's head. The browser acts on it from there.

The course tried it with prefetch, as here, and with modulepreload in its place. Read each row across: the hint, what the browser does when the page adds it, and what's left to do at the tap.

Two hints for the editor's chunk
The hintWhat the browser does when it's addedWhat's left at the tap
prefetchIt fetches the file at the lowest priority, into the HTTP cache.Read the file from the cache, then parse and run it.
modulepreloadIt fetches the file at high priority, then parses and compiles it into the page's module map.Run it.

Here are the waits after the tap:

The wait for the editor, four waysPop out in the code editor (opens in a new tab)JavaScript
// Asha taps "Write a review". How long until the editor appeared, in ms after the tap.const waits = { "one file": 102, "split, no hint": 980, "split, prefetch": 379, "split, modulepreload": 380 }; for (const [setup, wait] of Object.entries(waits)) console.log(`${setup}: ${wait} ms`);
  • Line 2 holds each way's wait, in ms after the tap.
  • Line 4 prints one file: 102 ms, split, no hint: 980 ms, split, prefetch: 379 ms and split, modulepreload: 380 ms.

Both hints cut the wait by about 600 ms. With prefetch, the chunk came from the HTTP cache at the tap. Nothing crossed the network. With modulepreload, there was no request at all. The chunk was already in the module map.

The split tea list after Asha taps Write a review. No hint: at 0.2 s, Loading the editor…; at 0.6 s, Loading the editor…; at 1 s, the editor; the editor appeared 980 ms after the tap. Prefetch: at 0.2 s, Loading the editor…; at 0.6 s, the editor; at 1 s, the editor; the editor appeared 379 ms after the tap.After Asha taps Write a review0.2 s after0.6 s after1.0 s afterNo hinteditor at 980 msThe tea list with a line that says Loading the editor… where the editor will goThe tea list with a line that says Loading the editor… where the editor will goThe tea list with the review editor open: a toolbar and a box that asks Which tea did you try?Prefetcheditor at 379 msThe tea list with a line that says Loading the editor… where the editor will goThe tea list with the review editor open: a toolbar and a box that asks Which tea did you try?The tea list with the review editor open: a toolbar and a box that asks Which tea did you try?
Real frames from the visit in the middle of three. With no hint, the editor's file came over the network after the tap. Prefetched, it was already in the cache.

Why not faster still? The editor's code had finished running 119 ms after the tap with prefetch, and 103 ms after it with modulepreload. The rest was React. Once React shows a loading line, it keeps it for at least 300 ms:

React's rule for loading linesText
// packages/react-reconciler/src/ReactFiberWorkLoop.js, React 19.3.1, line 528
const FALLBACK_THROTTLE_MS: number = 300;

Line 1 names the file in React's source. Line 2 is the rule: 300 ms. The comment where React uses it says why: it waits "so that we don't show too many loading states too quickly." Without the rule, the loading line could flash on and off.

Did the hint slow down the first load? No. Chrome sent the prefetch as soon as the load event fired, at its lowest priority. The tea list's own chunks had already arrived. The page answered at 2,280 ms, against 2,274 ms with no hint.

But a hint isn't free. Every visitor who never taps still downloads the chunk: here, 50.5 KB each. So hint only what visitors are likely to use. Bundlers can write the hint for you, too. Exercise 6 asks how webpack does it.

Playground: the wait after the tap
What happened
  1. At the tapnothing to fetch: the editor came with app.js
  2. The editor appeared102 ms after the tap
Try it yourself
Asha taps Write a review, 10 seconds in

Pick how the editor's code reaches the phone. The rows follow the editor's file, then the screen, from the trace and the page's own notes.

From the visit in the middle of three, in Chrome 157 on a phone's screen, DevTools' Slow 4G, a CPU 4 times slower.

Why it matters: a chunk fetched while the network is idle is ready when the tap comes. Safari doesn't support prefetch by default, so modulepreload reaches more phones.

How big, and how many: chunk size and count

Ravi's tea list is now 8 files. Is more always better? Here they are, by when each one loads:

The split tea list's 8 files, sized by what each sends. With the tea list: app.js, 56.0 KB; React, 3.1 KB; Tea list, 50.9 KB; debounce, 1.0 KB. On another page: Cart, 24.7 KB; Checkout, 24.8 KB; Account, 24.0 KB. On a tap: Review editor, 50.5 KB.With the tea listapp.js, 56.0 KBReact, 3.1 KBTea list, 50.9 KBdebounce, 1.0 KB4 files, 111.1 KBOn another pageCart, 24.7 KBCheckout, 24.8 KBAccount, 24.0 KBone page's file at a timeOn a tapReview editor, 50.5 KBonly after the tap
Each card is one file of the split tea list, as tall as the bytes it sends. The tea list loads the four on the left. The rest wait for a page or a tap. React comes in two parts: the small React card is the part every page shares, and React DOM, the big part, is inside app.js.

Each file costs a little extra. It's a request of its own, with its own headers: the short notes that travel with every request and response. And it's packed on its own. Brotli finds repeats only inside one file, as lesson 22.2 showed with its vacuum bags. The course measured how much that costs:

The same code, as separate files and as onePop out in the code editor (opens in a new tab)JavaScript
// Sent with Brotli, in bytes: the same code as separate files, and joined into one.const teaListFiles = { separate: 113799, joined: 112275 }; // the tea list's 4 first filesconst features = { separate: 47020, joined: 25075 }; // its 40 small features, one file each console.log(`4 files: ${teaListFiles.separate - teaListFiles.joined} bytes more than one`);console.log(`40 files: ${(features.separate / features.joined).toFixed(2)} times one`);
  • Line 2 is the tea list's four first chunks, packed one by one, and the same four joined into one file.
  • Line 3 is the tea list's 40 small features, each built and packed as a chunk of its own, then joined.
  • Lines 5 and 6 print 4 files: 1524 bytes more than one and 40 files: 1.88 times one.

Four big chunks cost 1.5 KB more than one file. That's small next to what splitting saved. But the 40 small chunks packed to 45.9 KB, nearly twice the 24.5 KB of the same code joined. It's like posting 40 small parcels instead of one box. Each needs its own wrapping and its own stamp.

All 8 files together send 235.1 KB, which is 9.0 KB more than one file. Only a visitor who opens every page and writes a review pays all of it. Asha, reading the tea list, pays 111.1 KB.

Bundlers have settings that keep chunks from getting too small, or too many. Read each row across: the bundler, the setting, and what it does.

Chunk size settings in three bundlers
BundlerSettingWhat it does
webpacksplitChunks.minSize: 20,000 bytes in productionIt won't make a new shared chunk smaller than this, counted before minifying and compressing.
webpackmaxInitialRequests and maxAsyncRequests: 30 eachAt most 30 files at page load, and 30 for one import(). Bigger chunks win when it has to merge.
Rollupoutput.experimentalMinChunkSizeIt merges chunks smaller than this into others, even if some pages then load code they don't use.
Vitebuild.chunkSizeWarningLimit: 500 kBIt warns about any chunk bigger than this, as built, because size as built is what costs time to run.

How many is too many? Chrome's team worked on this with Next.js. They saw "a slight performance overhead only after splitting aggressively to hundreds of requests." They chose at most 25 chunks at page load, each at least 20 KB. web.dev's guide to code splitting names the balance: compression, caching, and how long each chunk takes to run.

Why it matters: cut where a cut saves a lot, at pages and at big components. Don't give every module a file of its own.

Split it, or keep it?

Here are parts of a shop's app. Sort each one by where its code should go:

Split it, or keep it?
  • The checkout page
  • The account page, with past orders
  • The review editor, built on Quill
  • A map of the shop that opens when Asha taps Find us
  • The tea list's heading and its first eight teas
  • React, which every page uses
  • The search box at the top of the tea list
  • A small share button under each tea, 2 KB of code
Try it yourself
0 of 8 correct

Sort each part by where its code should go.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Splitting by route makes the first load faster on its own." No. Without modulepreload, the split tea list answered at 3,596 ms, later than one file. Its chunks were found one after another.
  • "More, smaller chunks are always better." No. The 40 small features packed to 45.9 KB as 40 files, and 24.5 KB as one.
  • "modulepreload runs the module." No. It fetches the module and gets it ready. It runs only when something imports it.
  • "A prefetched component appears the moment you tap." No. The editor's code ran 119 ms after the tap, but React kept its loading line until about 380 ms.
  • "import() fetches the file every time it runs." No. A module loads and runs once per page. MDN says later imports "don't even result in HTTP requests".
  • "Code splitting removes code." No. The split files together sent 9.0 KB more than one file. Splitting moves code to when it's needed. Tree shaking removes it.

Practice exercises

Answer each one first, then open the hints if you need them.

Exercise 1 · Warm-upWhat prints last?

loadEditor stands in for import(). What does the last printed line say?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// A stand-in for import("./review-editor.jsx"): a promise of the file's exports.
const loadEditor = () => Promise.resolve({ default: "The editor" });

console.log("Asha taps Write a review");
loadEditor().then((module) => console.log(`${module.default} is ready`));
console.log("Loading the editor…");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe checkout in the first load

    Ravi tries putting the checkout's chunk in the tea list's first load too. What does this sum print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Sent with Brotli, in bytes: the tea list's files, split by route.
    const first = { "app.js": 57369, "chunk-RJHX4K5A.js": 3197, "teas-WE6RLSE5.js": 103881 };
    const checkout = 25406;
    
    const total = Object.values(first).reduce((sum, bytes) => sum + bytes, 0) + checkout;
    console.log(`${(total / 1024).toFixed(1)} KB`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePreloaded, when did the tea list's chunk arrive?

      In the visit split by route, with modulepreload, when did the last byte of teas-WE6RLSE5.js arrive, in ms?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe editor's chunk, with no hint

        With no hint, how long after the tap did the last byte of the editor's chunk arrive, in ms?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThe tap's request in DevTools

          Open the copy of the split tea list in Chrome. In DevTools' Network panel, show the Priority column. Then tap Write a review. What priority does the new JavaScript file get?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeLet webpack write the hint

            webpack can add the prefetch hint itself. You put a comment inside import(). What does the comment say?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask: what does this file need now, and what can wait?

              The code splitting quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does import("./pages/cart.jsx") give back, the moment it runs?

                Choose an answer to see the explanation.

              2. Question 2 of 7Split by route with no hints, the tea list could answer later than with one file. Why?

                Choose an answer to see the explanation.

              3. Question 3 of 7Which hint gets a module fetched and compiled now, so it's ready to run when it's imported?

                Choose an answer to see the explanation.

              4. Question 4 of 7With the editor's file prefetched, why did the editor still take about 380 ms to appear?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does this sum about prefetching for every visitor print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 10 visitors open the tea list. 1 of them writes a review. The editor's file: 50.5 KB.
                const visitors = 10;
                const reviewers = 1;
                const editorKb = 50.5;
                
                console.log(`${(visitors * editorKb).toFixed(1)} KB downloaded, for ${reviewers} review`);

                Choose an answer to see the explanation.

              6. Question 6 of 7What goes wrong in this WriteReview component?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { lazy, useState } from "react";
                
                export function WriteReview() {
                  const ReviewEditor = lazy(() => import("./review-editor.jsx"));
                  const [text, setText] = useState("");
                  // ...
                }

                Choose an answer to see the explanation.

              7. Question 7 of 7The tea list's 40 small features, as 40 files or joined into one: which sends fewer bytes?

                Choose an answer to see the explanation.

              Key takeaways

              • Code splitting cuts one JavaScript file into chunks. A page loads what it needs now, and the rest when it's needed.
              • Split by route with import() and lazy. Code that several chunks use, like React, goes in a shared chunk.
              • A chunk's imports are found only after it arrives. Name a page's chunks up front with modulepreload.
              • Split heavy components that wait for a tap, like an editor. Check what rides along in shared chunks.
              • Fetch the next chunk while the network is idle, with prefetch or modulepreload, after the page has loaded.
              • Small chunks pack worse. Cut at pages and big components, not at every module.

              Remember the one-liner.
              Send each page the code it needs now, name its chunks up front, and fetch the next ones while the network is idle.

              Sources

              The facts on this page come from these specifications, docs and source code:

              Coming next: A diet for dependencies. Splitting moved code to when it's needed. Next, find packages you can swap for smaller ones, or drop.

              CompleteFrontend Clear concepts. Working examples.