completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

ES modules in the browser

See how the browser loads a graph of native modules level by level, what modulepreload and import maps change, and when one bundle still loads faster.

By the end, you can
  • 01
    Explain how a module graph loadsWhy the browser finds a module's imports only after it arrives, and why each level of imports costs a round trip.
  • 02
    Use modulepreload and import mapsList a whole graph up front, give React a short name, and let one changed file change alone.
  • 03
    Choose modules or a bundleWeigh first visits, return visits and changes, and know when one bundle still loads faster.

Ravi takes the bundler away

Ravi reads two guides on the same evening. Both are about how to send JavaScript to the browser. And they don't agree.

The first is the guide to importmap-rails, a tool for the Rails web framework. It says you can send many small files instead of one big one. It says that, thanks to HTTP/2, this "no longer carries a material performance penalty". In plain words: many files no longer cost much time. HTTP/2 is the version of HTTP that lets the browser ask for many files at once, over one connection. Lesson 4.5 showed it.

The second is the guide to Vite, a popular build tool. It says that sending files this way "is still inefficient due to additional network round trips from nested imports." A round trip is the time for a request to reach the server and for the answer to start coming back. In plain words: files that import other files cost extra trips.

Who is right? Ravi tries it on the tea list.

Until now, a bundler built the tea list. A bundler is a tool that joins many JavaScript files into one, like esbuild in lesson 22.2. Ravi takes it away. Now each file goes to the browser on its own, as a module: a JavaScript file that can import code from other files. Here is one of them:

tea-card.js, one of the tea list's modulesJSX
import { Price } from "./price.js";import { StrengthBar } from "./strength-bar.js"; export function TeaCard({ tea }) {  return (    <li className={tea.sale ? "tea sale" : "tea"}>      <a href="#">{tea.name}</a>      <Price amount={tea.price} />      <StrengthBar value={tea.strength} />    </li>  );}
  • Lines 1 and 2 import two more modules: price.js and strength-bar.js.
  • Lines 4 to 12 draw one tea: its name, its price and its strength bar.

To draw a tea, the browser needs all three files. But it learns about price.js only by reading line 1 of tea-card.js. Remember that. This whole lesson grows from it.

Ravi still keeps one small build step. It turns each file's JSX, the HTML-like tags React uses, into plain JavaScript. It also gives each file a fingerprint: a short code made from the file's content, in its name. So tea-card.js is sent as tea-card-RKJUNIUU.js. Lesson 15.2 showed why: a file with a fingerprint can stay in the cache for a year. Then the build writes the top of the page:

The modules page's head, shortenedHTML
<script type="importmap">{  "imports": {    "react": "./vendor/react-F2IRJJWW.js",    "react/jsx-runtime": "./vendor/jsx-runtime-F7LXXYMP.js",    "react-dom/client": "./vendor/react-dom-client-25HZSAN7.js",    "./src/app.js": "./src/app-4KD5HBNT.js",    …    "./src/lib/money.js": "./src/lib/money-BNM4P4AD.js"  }}</script><script type="module" src="./src/main-RLGB5T4Z.js"></script>
  • Lines 1 to 12 are an import map. It tells the browser which file each name stands for.
  • Lines 4 to 6 give a file to each of the three names the code uses for React. React comes from the shop's own server here.
  • Line 7 sends ./src/app.js to the file with its fingerprint. Line 8 stands for 31 more lines like it. Line 9 is the last one, for money.js.
  • Line 13 loads main.js, the first module. The browser finds all the others by itself.

The tea list has 39 modules: 34 of its own, and 5 of React's. React's three files share their code through two more files.

Then Asha opens the tea list on the bus. "Asha's phone" is the stand-in from lesson 22.1: Chrome 157 on a phone's screen, DevTools' Slow 4G network, and a CPU 4 times slower. The course loaded the tea list three ways, each with an empty cache:

  • Native modules: Ravi's 39 modules, as above. Native means the browser loads them itself.
  • With modulepreload: the same modules, plus one line in the page for each file. You'll meet it below.
  • One bundle: the same code, joined by esbuild into one file, as before.

Here is what Asha saw:

What Asha's phone showed on the three versions of the tea list, at three moments. At 1.0 s: Modules, shop's name; Modules + preload, shop's name; Bundle, shop's name. At 2.1 s: Modules, shop's name; Modules + preload, teas; Bundle, teas. At 5.0 s: Modules, teas; Modules + preload, teas; Bundle, teas.at 1.0 sat 2.1 sat 5.0 sModulesOnly the shop's name at the top; the rest is whiteshop's nameOnly the shop's name at the top; the rest is whiteshop's nameThe tea list: the heading Our teas, the Show all 300 teas button and the first teas with their pricesteasModules + preloadOnly the shop's name at the top; the rest is whiteshop's nameThe tea list: the heading Our teas, the Show all 300 teas button and the first teas with their pricesteasThe tea list: the heading Our teas, the Show all 300 teas button and the first teas with their pricesteasBundleOnly the shop's name at the top; the rest is whiteshop's nameThe tea list: the heading Our teas, the Show all 300 teas button and the first teas with their pricesteasThe tea list: the heading Our teas, the Show all 300 teas button and the first teas with their pricesteas
Real frames from Asha's phone, showing the top of the screen. At 1.0 s, all three showed only the shop's name. At 2.1 s, the page with modulepreload and the bundle showed the teas. The modules page showed them only after 4.9 s.

At one second, all three showed the shop's name. The page's HTML is small, and it came about as fast each time. At 2.1 seconds, two of them showed the teas. The modules page made Asha wait until 4.9 seconds.

Here are the numbers. Read each row across: how the tea list was sent, its files, their size, then three moments.

  • Sent with Brotli is the size after the server packs each file, as lesson 22.2 showed. A KB is 1,024 bytes.
  • Teas on screen is when Chrome painted the tea list, the page's largest contentful paint.
  • Could answer a tap is as in lesson 22.1. It's 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.
The tea list three ways, on Asha's phone, first visit
How the tea list was sentJavaScript filesSent with BrotliShop's name on screenTeas on screenCould answer a tap
Native modules3989.2 KB677 ms4,925 ms4,925 ms
Native modules with modulepreload3989.2 KB710 ms2,035 ms2,035 ms
One bundle174.2 KB677 ms1,812 ms1,812 ms

Same code, same phone, same network. Without the bundler, the teas came 3.1 seconds later. With one line per file in the page, they came only 0.2 seconds after the bundle. Why it matters: dropping the bundler can cost a visitor seconds. But most of that cost can be won back.

Real-life analogyA reading list that grows as you read

You need a book for a school project. You ask for it at the library desk, and it takes ten minutes to come. Its first page lists three more books you need. You ask for them together, and wait ten more minutes. Each of those names more books. Each round costs ten minutes, however many books you ask for. A friend who has read them all could hand you one folder with every chapter in it.

In real life: The first book
In web performance: main.js, named in the page
In real life: The books listed on its first page
In web performance: The files main.js imports
In real life: One round at the library desk
In web performance: One round trip on the network
In real life: Asking for several books in one round
In web performance: HTTP/2 asking for many files at once
In real life: The friend's folder
In web performance: One bundle
The main idea

The browser learns what a module imports only by reading it. So it loads a graph of modules one level at a time, with a round trip for each level. A bundler does that joining once, when you build. modulepreload and import maps help modules close the gap.

The JavaScript lessons Module loading in browsers and Module resolution & import maps explain the rules inside. Here, you measure what they cost. The lesson follows Ravi's questions:

  • Why did the modules page take so long? How the browser loads a module graph.
  • Was it the number of files? Deep import chains.
  • How did one line per file win it back? modulepreload for the whole graph.
  • What does the import map do? Import maps.
  • What happens when Asha comes back? Coming back, and a one-file change.
  • So should Ravi bundle? When bundling still wins.

First, watch the three pages fill in. Move the slider:

Playground: three versions, moment by moment
The tea list, three ways
  1. ModulesOnly the shop's name at the top; the rest is whiteshop's name
  2. Modules + preloadThe tea list: the heading Our teas, the Show all 300 teas button and the first teas with their pricesteas
  3. BundleThe tea list: the heading Our teas, the Show all 300 teas button and the first teas with their pricesteas
Try it yourself
Asha's screen at 2,100 ms

Move the slider. Each frame is the last one Chrome had drawn by then, on the load in the middle of three. The code is the same on all three pages. Only the way it ships differs.

Chrome 157 on a phone's screen, DevTools' Slow 4G, a CPU 4 times slower, an empty cache.

How the browser loads a module graph

The tea list's modules import each other. Together they form a module graph: a map of which file imports which. main.js is at the top. It imports app.js and two of React's files. app.js imports the tea page. And so on, down to money.js, which imports nothing.

How does the browser walk that graph? The HTML standard spells it out. For each module, it does four things, in order:

  1. It asks the server for the file.
  2. It waits until the whole file has arrived.
  3. It reads the file, and finds its import lines.
  4. It asks for every file those lines name, unless it already has it.

Step 4 is step 1 again, one level down. So the browser can't ask for a file until the file that imports it has arrived. Here is the tea list's longest chain, from main.js down to money.js:

The chain to money.jsPop out in the code editor (opens in a new tab)JavaScript
// The modules page: when Chrome asked for each file on the way to money.js (ms).const chain = [  ["main.js", 608],  ["app.js", 1185],  ["teas-page.js", 1786],  ["tea-list.js", 2362],  ["tea-card.js", 2968],  ["price.js", 3552],  ["money.js", 4146],];for (const [file, asked] of chain) console.log(`${file}: asked for at ${asked} ms`);
  • Lines 3 to 9 hold seven files. Each has the moment Chrome asked for it, in ms.
  • Line 11 prints one line for each, from main.js: asked for at 608 ms to money.js: asked for at 4146 ms.

Each file was asked for about 590 ms after the one before. That's one round trip on Asha's network, plus the moment the file took to arrive. Lesson 22.4 showed that DevTools' Slow 4G adds 562.5 ms to every request. Seven files meant seven of those trips, one after another.

And while the files were arriving, nothing ran. The browser runs a module graph only once every module in it has arrived and been linked. Linking connects each import to the export it names. The JavaScript lesson Module loading in browsers walks through it.

The last file, then the codePop out in the code editor (opens in a new tab)JavaScript
// The modules page: the last file, the first code, the teas (ms).const moneyArrived = 4725;const codeStarted = 4732;const teasOnScreen = 4925; console.log(`The code started ${codeStarted - moneyArrived} ms after money.js arrived.`);console.log(`The teas came ${teasOnScreen - codeStarted} ms after that.`);
  • Lines 2 to 4 hold three recorded times: when money.js arrived, when the page's code started, and when the teas appeared.
  • Lines 6 and 7 print The code started 7 ms after money.js arrived. and The teas came 193 ms after that.

money.js is 42 bytes. It only writes a price, like £4.50. Yet the whole tea list waited for it.

Real-life analogyA play waits for its whole cast

A school play can't start until every actor is on stage. The lead came an hour early. The actor who says one line came last, stuck in traffic. The curtain still waited for him.

In real life: Each actor
In web performance: Each module
In real life: The actor with one line
In web performance: money.js, 42 bytes
In real life: The curtain going up
In web performance: The code starting to run
In real life: Waiting for the last actor
In web performance: Waiting for the deepest level

Why it matters: on a modules page, the slowest level holds back everything. A tiny file deep in the graph can cost as much time as a big one.

Deep import chains

Was it the number of files that made the modules page slow? There were 39. Here is the graph level by level. A file's level is how many steps it is from the page. main.js is level 1. The files it imports are level 2, and so on.

The modules page, level by levelPop out in the code editor (opens in a new tab)JavaScript
// The modules page, level by level: how many files, and when Chrome asked for the first (ms).const levels = [  [1, 608],  [3, 1185],  [3, 1773],  [5, 2362],  [24, 2968],  [2, 3552],  [1, 4146],];for (const [n, [files, asked]] of levels.entries())  console.log(`Level ${n + 1}: ${files} ${files === 1 ? "file" : "files"}, first asked at ${asked} ms`);
  • Lines 3 to 9 list seven levels. Each holds how many files it has, and when Chrome asked for the first of them.
  • Line 11 goes through the list. Line 12 prints one line per level, from Level 1: 1 file, first asked at 608 ms to Level 7: 1 file, first asked at 4146 ms.

Look at line 7, level 5. It holds 24 files: tea-card.js and the 23 extras below the teas. Chrome asked for all 24 within 16 ms of each other. That's HTTP/2 at work: many requests at once, on one connection. And the next level began 584 ms after level 5 did. After level 1, which holds one file, the gap was 577 ms.

So the number of files cost little. The number of levels cost a lot. The time grows with the depth of the graph: the number of levels in its longest chain. It's like asking for directions in a town where each person knows only the next turn. Ten shops on one street don't slow you down. Ten turns do.

When each level of the tea list's modules was asked for, and when its last file arrived, on two pages. Native modules: each level waits for the one before: level 1, 1 files, asked at 608 ms, all in by 1,182 ms; level 2, 3 files, asked at 1,185 ms, all in by 2,103 ms; level 3, 3 files, asked at 1,773 ms, all in by 2,691 ms; level 4, 5 files, asked at 2,362 ms, all in by 2,976 ms; level 5, 24 files, asked at 2,968 ms, all in by 3,745 ms; level 6, 2 files, asked at 3,552 ms, all in by 4,144 ms; level 7, 1 files, asked at 4,146 ms, all in by 4,725 ms; teas on screen at 4,925 ms. With modulepreload: every file asked for at once: level 1, 1 files, asked at 622 ms, all in by 1,198 ms; level 2, 3 files, asked at 622 ms, all in by 1,843 ms; level 3, 3 files, asked at 623 ms, all in by 1,534 ms; level 4, 5 files, asked at 623 ms, all in by 1,292 ms; level 5, 24 files, asked at 624 ms, all in by 1,492 ms; level 6, 2 files, asked at 631 ms, all in by 1,509 ms; level 7, 1 files, asked at 631 ms, all in by 1,517 ms; teas on screen at 2,035 ms.Native modules: each level waits for the one beforelevel 1, 1 filelevel 2, 3 fileslevel 3, 3 fileslevel 4, 5 fileslevel 5, 24 fileslevel 6, 2 fileslevel 7, 1 fileteas on screen, 4,925 msWith modulepreload: every file asked for at oncelevel 1, 1 filelevel 2, 3 fileslevel 3, 3 fileslevel 4, 5 fileslevel 5, 24 fileslevel 6, 2 fileslevel 7, 1 fileteas on screen, 2,035 ms0 s1 s2 s3 s4 s5 s
Each bar runs from when Chrome asked for a level's first file to when its last file arrived. Without modulepreload, each level started only after the level above it had arrived, like a staircase. With it, every level started at the same moment.

V8's guide to modules gives a simple rule. Native modules are fine in production only for small apps. That means "less than 100 modules in total", and "a maximum depth less than 5". The tea list has 39 modules, well under 100. But it's 7 levels deep.

Why it matters: keep import chains short. A helper that imports a helper that imports a helper adds a round trip each time. Try both pages, level by level:

Playground: the module graph, level by level
The Modules page, first visit
  1. Level 1, 1 fileasked at 608 ms, all in by 1,182 ms
  2. Level 2, 3 filesasked at 1,185 ms, all in by 2,103 ms
  3. Level 3, 3 filesasked from 1,773 ms to 2,106 ms, all in by 2,691 ms
  4. Level 4, 5 filesasked at 2,362 ms, all in by 2,976 ms
  5. Level 5, 24 filesasked from 2,968 ms to 2,984 ms, all in by 3,745 ms
  6. Level 6, 2 filesasked at 3,552 ms, all in by 4,144 ms
  7. Level 7, 1 fileasked at 4,146 ms, all in by 4,725 ms
  8. The code started running4,732 ms
  9. Teas on screen4,925 ms
Try it yourself
Native modules

Pick a page. Each row is one level of the tea list's module graph: how many files it holds, when Chrome asked for them, and when the last one had arrived. Then when the code started, and the teas.

From the first visit in the middle of three, in Chrome 157.

modulepreload for the whole graph

The second page used modulepreload, the hint from lesson 16.4 and lesson 22.4. It tells the browser about a module before anything imports it. The HTML standard says it "places the result into the appropriate module map for later evaluation." The module map is the page's list of the modules it has loaded. A preloaded module waits there until something imports it.

Lesson 22.4 used it for two chunks. Ravi's build goes further. It writes one line for every module in the graph:

The preload page's head, shortenedHTML
<link rel="modulepreload" href="./src/main-RLGB5T4Z.js"><link rel="modulepreload" href="./vendor/react-dom-client-25HZSAN7.js"><link rel="modulepreload" href="./vendor/jsx-runtime-F7LXXYMP.js">…<link rel="modulepreload" href="./src/lib/money-BNM4P4AD.js"><script type="module" src="./src/main-RLGB5T4Z.js"></script>
  • Lines 1 to 3 are the first three modules, in level order: main.js, then two of React's files.
  • Line 4 stands for 35 more lines. Line 5 is the last, for money.js.
  • Line 6 is the module script, as before.
Real-life analogyHanding over the whole reading list

Back at the library desk. This time, your teacher gives you the whole reading list on the first day. You hand it over at once, and every book comes in one round. You still read them in order. But you never wait to find out what comes next.

In real life: The whole reading list, on day one
In web performance: One modulepreload line per module
In real life: Every book in one round
In web performance: Every file asked for at once
In real life: Still reading them in order
In web performance: The modules still run when main.js needs them
Without and with modulepreloadPop out in the code editor (opens in a new tab)JavaScript
// The modules page, without and with modulepreload (ms).const without = { lastAsked: 4146, teas: 4925 };const preloaded = { lastAsked: 631, teas: 2035 }; console.log(`The last file was asked for ${without.lastAsked - preloaded.lastAsked} ms sooner.`);console.log(`The teas came ${without.teas - preloaded.teas} ms sooner.`);
  • Line 2 holds two recorded times without modulepreload: when Chrome asked for the last file, and when the teas appeared. Line 3 holds the same two with it.
  • Lines 5 and 6 print The last file was asked for 3515 ms sooner. and The teas came 2890 ms sooner.

With the list, Chrome asked for all 39 files within 9 ms of each other, right after the HTML. Seven rounds became one. The teas came at 2,035 ms, close to the bundle's 1,812 ms.

The list has costs of its own:

  • The page grew. 39 lines made the HTML 2,555 bytes bigger, or 172 bytes with Brotli. Rolldown's guide warns that thousands of such lines are "a performance issue in itself."
  • A tool has to write it. Every module must be on the list, and the list changes with every build. Ravi's build writes it from the graph.
  • Chrome fetches only what's listed. The HTML standard lets a browser also fetch a preloaded module's imports, but doesn't make it. Chrome's own code fetches the one file and stops there. web.dev advises listing "the flat list of its dependencies".

Why it matters: if you ship native modules, list every module the page needs. It turns a staircase of trips into one.

Import maps: short names and fingerprints

Ravi's import map does two jobs. Both are about names.

Short names for React

The extras import React like this: import { useState } from "react". But "react" isn't an address. It's a bare name: no ./, no /, no https:. On its own, the browser can't load it. A bundler looks such names up in node_modules. The browser can't.

Line 4 of the import map fills the gap. The lesson Module resolution & import maps covers every rule. For speed, three facts matter:

  • The import map sits inside the HTML. MDN says its src attribute "must not be specified", so it costs no request of its own.
  • It must come before the module scripts that use it.
  • It only changes addresses. Every file is still a request of its own.

Fingerprints in the map

The second job is about the cache. Say Ravi changes money.js. It gets a new fingerprint, so a new name. price.js imports it. If price.js named the file with its fingerprint, price.js would have to change too. Then tea-card.js, which imports price.js. And so on, all the way up to main.js.

Ravi's import lines keep the plain names, like ./price.js. The map holds the fingerprints. The HTML standard's own example maps /js/app.mjs to /js/app-8e0d62a03.mjs the same way. Here is Ravi's change:

money.js, before and after the changeJavaScript
// money.js, before the change:
export const money = (amount) => `£${amount}`;

// money.js, after it. The prices look the same.
const pounds = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });

export const money = (amount) => pounds.format(Number(amount));
  • Lines 1 and 2 are money.js before the change: the £ sign, then the amount.
  • Lines 4 to 7 are money.js after it. It uses the browser's own number formatter. The prices look the same: £4.50.
What the change renamesPop out in the code editor (opens in a new tab)JavaScript
// Which files get a new name when money.js changes.const inImportLines = ["money.js", "price.js", "tea-card.js", "tea-list.js", "teas-page.js", "app.js", "main.js"];const inImportMap = ["money.js"]; console.log(`Fingerprints in the import lines: ${inImportLines.length} new files`);console.log(`Fingerprints in the import map: ${inImportMap.length} new file`);
  • Line 2 lists the files that would get new names if the fingerprints were in the import lines. Line 3 lists them with the fingerprints in the map.
  • Lines 5 and 6 print Fingerprints in the import lines: 7 new files and Fingerprints in the import map: 1 new file.
What a change to money.js gives a new name, two ways. Fingerprints in the import lines: the page changes, and main.js, app.js, teas-page.js, tea-list.js, tea-card.js, price.js, money.js get new names; 0 files keep theirs. Fingerprints in the import map: the page changes, and money.js gets a new name; 6 files keep theirs.Fingerprints in the import linesthe pagechangedmain.jsnew nameapp.jsnew nameteas-page.jsnew nametea-list.jsnew nametea-card.jsnew nameprice.jsnew namemoney.jsnew name7 scripts to download againFingerprints in the import mapthe pagechangedmain.jsapp.jsteas-page.jstea-list.jstea-card.jsprice.jsmoney.jsnew name1 script to download again
Each file imports the one below it. A shaded file has changed, so Asha's phone downloads it again. With the fingerprints in the import lines, the change climbs all the way to main.js. With them in the import map, only money.js and the page change.
Real-life analogyA noticeboard of rooms

At a school, each timetable says "Maths with Ms Rao". It doesn't say which room. A noticeboard by the door lists each teacher's room. When Ms Rao moves to room 12, only the noticeboard changes. Nobody prints new timetables.

In real life: "Maths with Ms Rao" on a timetable
In web performance: An import line: ./money.js
In real life: The noticeboard by the door
In web performance: The import map
In real life: Ms Rao's room number
In web performance: The file with its fingerprint: money-BNM4P4AD.js
In real life: Ms Rao moving rooms
In web performance: money.js changing

Why it matters: with the fingerprints in the import map, a changed file changes alone. Rolldown's guide says bundlers can use import maps the same way.

Coming back, and a one-file change

V8's guide points to the other side. Native modules "might decrease initial load performance (cold cache), but could actually improve load performance for subsequent visits (warm cache)." A cold cache is an empty one, on a first visit. A warm cache already holds the files.

So the course had Asha come back. It loaded each page once, closed the tab and every connection, then loaded it again in a new tab. Every script now came from the disk cache. Only the page itself crossed the network, because the shop checks it on every visit.

Coming back, with every file cachedPop out in the code editor (opens in a new tab)JavaScript
// Coming back, with every file in the cache: teas on screen (ms).const modules = 897;const bundle = 858; console.log(`Modules: ${modules} ms. Bundle: ${bundle} ms.`);console.log(`The difference: ${modules - bundle} ms.`);
  • Lines 2 and 3 hold when the teas appeared on the second visit, in ms.
  • Lines 5 and 6 print Modules: 897 ms. Bundle: 858 ms. and The difference: 39 ms.

The seven levels were still there. But each one now took a moment, not a round trip. With a warm cache, the depth hardly mattered.

Then Ravi published his change to money.js. Asha came back once more:

Coming back after the changePop out in the code editor (opens in a new tab)JavaScript
// Coming back after the change to money.js: bytes downloaded, and teas on screen (ms).const modules = { bytes: 1372, teas: 1509 };const bundle = { bytes: 76660, teas: 1858 }; console.log(`Modules: ${modules.bytes} bytes, teas at ${modules.teas} ms`);console.log(`Bundle: ${bundle.bytes} bytes, teas at ${bundle.teas} ms`);
  • Lines 2 and 3 hold, for each page, the bytes that crossed the network and when the teas appeared.
  • Lines 5 and 6 print Modules: 1372 bytes, teas at 1509 ms and Bundle: 76660 bytes, teas at 1858 ms.

On the modules page, two files crossed the network: the page, and the new money.js. The other 38 came from the cache. The bundle's name had changed too, because its content had. So Asha's phone downloaded all of it again, React included. It's lesson 15.2's ring binder: swap one page, instead of printing the whole book again.

Why it matters: if your visitors come back often, and you publish often, small files let a change cost only what changed. Pick each visit below. The page with modulepreload is there too:

Playground: first visits, return visits and a change
Coming back
  1. Modules: files1 from the network, 39 from the cache
  2. Modules: downloaded1,173 bytes
  3. Modules: could answer a tap897 ms
  4. Modules + preload: files1 from the network, 39 from the cache
  5. Modules + preload: downloaded1,345 bytes
  6. Modules + preload: could answer a tap906 ms
  7. Bundle: files1 from the network, 1 from the cache
  8. Bundle: downloaded624 bytes
  9. Bundle: could answer a tap858 ms
Try it yourself
Coming back

Pick a visit. The rows show, for each version, how many files crossed the network and how many came from the cache, the bytes downloaded, and when the page could answer a tap.

From the load in the middle of three, in Chrome 157. Bytes are what crossed the network, headers included.

When bundling still wins

So who was right? Here is every visit side by side. Read each row across: how the tea list was sent, when it could answer a tap on each visit, and what the change cost.

Three ways, three visits, on Asha's phone
How the tea list shipsFirst visit: could answer a tapComing backAfter the changeDownloaded after the change
Native modules4,925 ms897 ms1,509 ms1,372 bytes
Native modules with modulepreload2,035 ms906 ms1,446 ms1,527 bytes
One bundle1,812 ms858 ms1,858 ms76,660 bytes

On the first visit, the bundle won. On the visit after a change, the modules won. With the cache full and no change, all three were within 50 ms. Three things kept the bundle ahead on the first visit.

It packs smaller

The same code, packed three waysPop out in the code editor (opens in a new tab)JavaScript
// The tea list's JavaScript with Brotli, three ways (bytes).const oneByOne = 91359;const joinedFirst = 76963;const bundled = 75956; const kb = (bytes) => (bytes / 1024).toFixed(1);console.log(`39 files packed one by one: ${kb(oneByOne)} KB`);console.log(`The same 39 files joined, then packed: ${kb(joinedFirst)} KB`);console.log(`The bundle: ${kb(bundled)} KB`);
  • Lines 2 to 4 hold the tea list's JavaScript with Brotli: the 39 files packed one by one, the same 39 joined into one file before packing, and the bundle.
  • Lines 7 to 9 print 39 files packed one by one: 89.2 KB, The same 39 files joined, then packed: 75.2 KB and The bundle: 74.2 KB.

Brotli finds repeats only inside one file, as lessons 22.2 and 22.4 showed. Joined first, the same code packed 14 KB smaller. The bundler saved 1 KB more, partly because it doesn't need the import and export lines between files.

It makes one request

The bundle is one request. The modules page made 39, in seven rounds. Even with modulepreload, the 39 files shared Asha's slow line. The last one arrived 236 ms after the bundle had. Rolldown's guide puts it plainly: "HTTP/2 does not mean you can stop caring about number of HTTP requests."

It needs a build step either way

Ravi didn't really drop his build. He still turned JSX into JavaScript, added fingerprints, and wrote the import map and the list. Rolldown's guide says Rails' own setup does the same. Its files "still go through a build step in order to fingerprint the assets and generate the import map and modulepreload directives."

Real-life analogyA removal van, or forty taxis

Moving house, you can hire one van, packed tight. Or you can send forty taxis, each with a box. The taxis can all leave together. But each one needs its own booking, and its own space around the box. The van carries the same things in less room.

In real life: One van, packed tight
In web performance: One bundle
In real life: Forty taxis
In web performance: 39 modules
In real life: Booking each taxi
In web performance: A request for each file
In real life: Space around each box
In web performance: Brotli packing each file on its own

The middle way is lesson 22.4's: a few chunks, the files a bundler splits the code into, each named in the page with modulepreload. Each chunk packs well, and a change re-sends only the chunk that holds it. web.dev's article on modulepreload says the same: "consider bundling your application into several chunks". V8's guide sums it up: "continue using bundlers before deploying modules to production."

Why it matters: bundle for first visits. Native modules suit small, shallow apps, pages people come back to often, and your own computer while you write the code.

Modules or a bundle?

Here are six places JavaScript ships. Sort each one by how its code should go out:

Modules or a bundle?
  • A shop's app with 400 modules, opened by many first-time visitors on phones
  • A landing page that most people see once
  • An app that imports a package shipped as hundreds of small files
  • A settings page with 8 shallow modules, used every day by the same staff
  • Your own computer while you write the code
  • A small widget of 12 modules, 3 levels deep, with a modulepreload line for each
Try it yourself
0 of 6 correct

Sort each case by how its JavaScript should ship.

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

Common mix-ups

  • "HTTP/2 makes many small files free." No. It asked for level 5's 24 files at once. But it couldn't skip the seven levels. The modules page showed the teas 3.1 seconds after the bundle.
  • "A tiny module is quick to load." No. money.js is 42 bytes. Seven levels down, Chrome asked for it at 4,146 ms.
  • "modulepreload on main.js fetches its imports too." No. The HTML standard allows it, but Chrome fetches only the file named. List every module.
  • "An import map costs a request." No. It sits inside the HTML. It can't have a src.
  • "Native modules send less code." No. Packed one by one, the tea list's 39 files were 15 KB bigger than the bundle.
  • "A changed file always means one download." No. Only if the files that import it keep their names. With the fingerprints in the import lines, a change to money.js renames 7 files.

Practice exercises

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

Exercise 1 · Warm-upCount the levels

Four small modules import each other like this. Counting shop.js as level 1, how many levels does the browser walk?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// shop.js
import { cart } from "./cart.js";

// cart.js
import { tax } from "./tax.js";
import { money } from "./money.js";

// tax.js
import { rates } from "./rates.js";

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

    Exercise 2 · Warm-upOne change, with the map

    Ravi changes strength-bar.js. Its fingerprint lives in the import map. On Asha's next visit, how many JavaScript files does her phone download?

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

      Exercise 3 · PracticeLevel 2, with modulepreload

      On the page with modulepreload, by when had every file of level 2 arrived, in ms?

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

        Exercise 4 · PracticeAfter the change, with modulepreload

        Coming back after the change, when could Asha tap on the page with modulepreload, in ms?

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

          Exercise 5 · ChallengeThe biggest file in DevTools

          Open the course's copy of the modules page in Chrome. In DevTools' Network panel, which JavaScript file is the biggest?

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

            Exercise 6 · ChallengeFingerprints in the import lines

            Suppose Ravi's import lines held the fingerprints, with no import map for them. He changes show-all-button.js. How many of the tea list's files get a new name, show-all-button.js included?

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

              Check your understanding

              For each question, ask two things: when could the browser find the file, and did it have to cross the network?

              The ES modules quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the levels example print for level 5?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The modules page, level by level: how many files, and when Chrome asked for the first (ms).
                const levels = [
                  [1, 608],
                  [3, 1185],
                  [3, 1773],
                  [5, 2362],
                  [24, 2968],
                  [2, 3552],
                  [1, 4146],
                ];
                for (const [n, [files, asked]] of levels.entries())
                  console.log(`Level ${n + 1}: ${files} ${files === 1 ? "file" : "files"}, first asked at ${asked} ms`);

                Choose an answer to see the explanation.

              2. Question 2 of 7Why did Chrome ask for money.js so late on the modules page?

                Choose an answer to see the explanation.

              3. Question 3 of 7Why did the teas wait for money.js, a file of 42 bytes?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does a modulepreload line for every module change?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why can one changed file stay one new download when the fingerprints live in the import map?

                Choose an answer to see the explanation.

              6. Question 6 of 7Coming back with every file in the cache, why were the three versions so close?

                Choose an answer to see the explanation.

              7. Question 7 of 7When does one bundle still win?

                Choose an answer to see the explanation.

              Key takeaways

              • The browser finds a module's imports only by reading it, so it loads a module graph one level at a time.
              • Each level costs a round trip. The depth of the graph matters more than the number of files.
              • A module graph runs only when every module has arrived. The deepest file holds back the whole page.
              • One modulepreload line per module turns the levels into one round. Chrome fetches only what's listed.
              • An import map gives bare names an address. Holding the fingerprints, it lets one change touch one file.
              • One bundle won the first visit; native modules won the visit after a change. Bundle, or split into a few chunks.

              Remember the one-liner.
              Modules load one level per round trip: list them up front, keep chains short, and bundle when first visits matter.

              Sources

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

              Coming next: The cost of hydration. The tea list's code now arrives fast. Next, what that code still has to do when the server has already drawn the page.

              CompleteFrontend Clear concepts. Working examples.