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.
- 01Split where it paysSplit an app by page and by heavy component with import() and React's lazy, and check which files each page loads.
- 02Stop files waiting on each otherSpot a chain of chunks in a trace, and name a page's files up front with modulepreload.
- 03Have 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:
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:
// 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.andSplit: 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.
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
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:
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:
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. lazywraps it. React's docs say it "lets you defer loading component's code until it is rendered for the first time." So React only callsimport()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. Itsfallbackis 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:
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.jsxand everything it imports, and minify it.--outdir=distnames 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:
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 pageEach 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.
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:
// 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 sendand782 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: 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, thenchunk-RJHX4K5A.js: asked at 1501, arrived at 2099andteas-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.
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:
<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 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: 3596andby 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:
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:
<script type="module" src="app.js"></script>
- app.jsasked at 606 ms, last byte at 1,498 ms
- chunk-RJHX4K5A.jsasked at 1,501 ms, last byte at 2,099 ms
- teas-WE6RLSE5.jsasked at 2,179 ms, last byte at 3,339 ms
- The tea list could answer a tapat 3,596 ms
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.
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:
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:
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
lazyandimport(), 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.
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:
import { debounce } from "lodash-es"; // through the barrel
import debounce from "lodash-es/debounce.js"; // the side doorLine 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:
// 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:
- app.js226.1 KB: React DOM and the address check
- The tea list loads226.1 KB in 1 file
Nothing. Every page and the editor came in app.js.
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.
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?
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:
<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.relsays which hint.hrefnames 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.
| The hint | What the browser does when it's added | What's left at the tap |
|---|---|---|
prefetch | It fetches the file at the lowest priority, into the HTTP cache. | Read the file from the cache, then parse and run it. |
modulepreload | It 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:
// 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 msandsplit, 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.
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:
// 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.
- At the tapnothing to fetch: the editor came with app.js
- The editor appeared102 ms after the tap
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.
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:
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:
// 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 oneand40 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.
| Bundler | Setting | What it does |
|---|---|---|
| webpack | splitChunks.minSize: 20,000 bytes in production | It won't make a new shared chunk smaller than this, counted before minifying and compressing. |
| webpack | maxInitialRequests and maxAsyncRequests: 30 each | At most 30 files at page load, and 30 for one import(). Bigger chunks win when it has to merge. |
| Rollup | output.experimentalMinChunkSize | It merges chunks smaller than this into others, even if some pages then load code they don't use. |
| Vite | build.chunkSizeWarningLimit: 500 kB | It 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:
- 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
Sort each part by where its code should go.
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.
loadEditor stands in for import(). What does the last printed line say?
// 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…");The editor is ready. A promise's function always runs after the code that's running now has finished, even when the promise is already settled. So the loading line prints first, as on the tea list.
Ravi tries putting the checkout's chunk in the tea list's first load too. What does this sum print?
// 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`);185.4 KB. The checkout's chunk would add 24.8 KB to every visit to the tea list, for a page Asha may not open.
In the visit split by route, with modulepreload, when did the last byte of teas-WE6RLSE5.js arrive, in ms?
2,113 ms. It was asked for at the same moment as app.js, so it no longer waited for app.js to run.
With no hint, how long after the tap did the last byte of the editor's chunk arrive, in ms?
873 ms after the tap. Most of the wait for the editor was this download: Slow 4G's delay, then 50.5 KB on a slow line.
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?
High. Chrome fetches a module that import() asks for at high priority, because the page needs it now. A prefetch goes out at the lowest priority, because it's for later.
webpack can add the prefetch hint itself. You put a comment inside import(). What does the comment say?
const ReviewEditor = lazy(() => import(/* webpackPrefetch: true */ "./review-editor.jsx"));/* webpackPrefetch: true */. webpack then adds a prefetch link for the editor's chunk once its parent chunk has loaded, so the browser can fetch it in idle time.
Check your understanding
For each question, ask: what does this file need now, and what can wait?
Question 1 of 7What does
import("./pages/cart.jsx")give back, the moment it runs?Choose an answer to see the explanation.
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.
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.
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.
Question 5 of 7What does this sum about prefetching for every visitor print?
Read the code, then predict// 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.
Question 6 of 7What goes wrong in this WriteReview component?
Read the code, then predictimport { 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.
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()andlazy. 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:
- ECMAScript Language Specification: Import Calls, Ecma TC39.
- import(), MDN.
- lazy, React.
- esbuild API: Splitting, Evan Wallace.
- esbuild API: Chunk names, Evan Wallace.
- rel="modulepreload", MDN.
- HTML Standard: Link type "modulepreload", WHATWG.
- Features: Build Optimizations, Vite.
- Reduce JavaScript payloads with code splitting, web.dev.
- rel="prefetch", MDN.
- HTML Standard: Link type "prefetch", WHATWG.
- React 19.3.1: ReactFiberWorkLoop.js, the fallback throttle, Meta.
- Code Splitting: Prefetching/Preloading modules, webpack.
- Code-split JavaScript, web.dev.
- SplitChunksPlugin, webpack.
- Configuration options: output.experimentalMinChunkSize, Rollup.
- Build options: build.chunkSizeWarningLimit, Vite.
- Improved Next.js and Gatsby page load performance with granular chunking, web.dev.
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.