Modules through a bundler
Learn how bundlers build module and chunk graphs, remove unused exports, combine scopes, load code on demand, and update modules during development.
- 01Follow what a bundler doesStart at the entry file and follow its imports. Then say which names are used, which files must stay, and which files load later.
- 02Read the code a bundler writesSpot renamed variables in one shared scope, a small runtime with a module cache, and code that loads a chunk later.
- 03Explain updates while you developExplain how HMR swaps a changed file into the open page, and why the page sometimes reloads instead.
A module system rebuilt early
A bundler is a build tool. It runs on your computer before anyone visits your site. It does three jobs:
- It reads your first file, called the entry.
- It follows every
importto find the other files. - It writes new files that the browser can load.
Later, the browser runs those new files. By then, the bundler has already decided which code goes in which file.
Bundling turns your source files and their imports into one or more output files. Each output file is called a chunk. While it bundles, the build can:
- remove code that nothing uses,
- put several small files into one shared scope,
- add a little extra code, called the runtime, that loads some chunks later.
The bundlers lesson shows how to use a bundler day to day. This lesson looks inside one. It builds on module records and module loading in Node.js.
Building the module graph
The bundler starts with the entry file. Each import in a file points to another file. The bundler follows those pointers again and again. The result is a module graph: a list of files, plus which file imports which.
const imports = { "main.js": ["cart.js"], "cart.js": ["price.js"], "price.js": [],};const graph = [];const todo = ["main.js"];while (todo.length) { const name = todo.shift(); if (graph.includes(name)) continue; graph.push(name); todo.push(...(imports[name] ?? []));}console.log(graph.join(","));Lines 1 to 5 list what each file imports. Line 6 makes an empty graph. Line 7 makes a to-do list that holds only main.js. Lines 8 to 13 repeat while the to-do list has names:
- Line 9 takes the first name off the to-do list.
- Line 10 skips the name if the graph already has it.
- Line 11 adds the name to the graph.
- Line 12 adds that file's imports to the to-do list.
Line 14 prints main.js,cart.js,price.js.
main.js. Each import arrow leads to the next file, and each file is added once.A step-by-step replay of the lesson's graph model. It shows the work a bundler does while it builds. It is not a real bundler.
script
console.log(graph.join(","));You pack for a trip. You start with your checklist. You pack each item on it. Then you pack what those items need, like a charger for your phone. You tick off each thing you pack, so you never pack the same charger twice.
- In real life: Your checklist says what to pack first
- In JavaScript: The entry file starts the graph
- In real life: Each item needs another item, like a phone needs its charger
- In JavaScript: Each import points to another file
- In real life: You don't pack the same charger twice
- In JavaScript: The bundler adds each file only once
- In real life: Things you don't need stay at home
- In JavaScript: Unused code can be left out
The graph order is not always the order of the output file. First the bundler reads every file and connects the imports. Then it picks an order where each file comes after the files it needs. This matters most in three cases:
- two files import each other (a cycle),
- a file is loaded later with
import(), - a file does work just by being loaded (a side effect, explained below).
Scope hoisting
Each file has its own scope: its own set of variable names. Normally a bundler wraps each file in a function to keep those scopes apart. Once the bundler knows the graph, it can sometimes skip the wrappers and put several files into one shared scope. This is called scope hoisting. The output gets smaller and faster, because there are fewer wrapper functions to call.
// before: two files, and both declare price// const price = 3;// const price = 5; // after scope hoisting: one scope, one name renamedconst price = 3;const cart_js_price = 5;console.log(price + cart_js_price);Lines 2 and 3 show two files that each declare price. In one shared scope, two const variables can't have the same name. So line 6 keeps the first price, and line 7 renames the second one to cart_js_price. Line 8 adds 3 and 5 and prints 8.
price. In one shared scope, two variables can't have the same name, so the bundler renames the second one to cart_js_price.You could put each small thing in its own pouch, and put the pouches in a bag. Or you could put everything straight into one bag. If two things have the same label, you rewrite one label first, so you can still tell them apart.
- In real life: Small pouches go into one big bag
- In JavaScript: Small files share one scope
- In real life: Two pouches have the label price
- In JavaScript: Two variables have the same name
- In real life: You rewrite one label
- In JavaScript: The bundler renames one variable
Scope hoisting must not change what your code does. The bundler must keep:
- every import and export working,
- the order in which files run,
- any name your code can see from outside.
If joining two files into one scope would change what the code does, the bundler keeps a wrapper function for that file instead.
How tree shaking decides
Tree shaking means leaving out code that nothing uses. The name comes from shaking a tree so the dead leaves fall off. The bundler only leaves code out when it is sure that is safe. It doesn't guess what a user might click. It checks two things:
- which exported names some other file imports,
- whether loading a file does work you can see.
A step-by-step replay of the lesson's small tree-shaking model. A real bundler also reads the code itself to find side effects.
script
console.log(result.modules.join(","));The replay starts at main.js. It marks total as used, because main.js imports it. Then it follows the imports that total needs: cart.js, then price.js. A real bundler must also keep any file that does work just by being loaded. For example, a file might:
- change a global variable,
- register a custom HTML element,
- start listening for events.
const result = treeShake(modules, "main.js");console.log(result.modules.join(","));main.js, cart.js, price.jsA file stays if it is the entry, if another file uses one of its exports, or if it has side effects.
{"main.js":[],"cart.js":["total"],"price.js":["price"]}The names each kept file gives to other files. An empty list means no name from that file is used.
The bundle keeps main.js, cart.js, price.js. Change one button at a time. Reset makes main.js use total again.
Change one button at a time. The first button changes what main.js imports. When it imports price.js but uses no names from it, the second button decides:
- If
price.jsis pure (it does no work when loaded), the bundle leaves it out. - If
price.jshas side effects (it does work when loaded), the bundle keeps it.
A file that nothing imports stays out either way. Reset puts back the import of total.
main.js imports price.js but uses no names from it. A pure file is left out. A file with side effects stays, because loading it does visible work.Side effects and package metadata
A side effect is work you can see that happens just because a file was loaded. For example, loading a file might:
- change a global variable,
- add a style rule to the page,
- register some code with the browser.
An exported function is not a side effect by itself. It does nothing until someone calls it. But a function call at the top level of a file runs as soon as the file loads, so it can be a side effect.
A package can say whether its files have side effects. It does this with the sideEffects field in its package.json file. A bundler that supports this field reads it. sideEffects: false is a promise: "if you use none of my exports, you can leave my files out." It does not promise that the exported functions do nothing when you call them.
import { formatPrice } from "./price.js";
console.log("theme registered");
export { formatPrice };Line 1 imports formatPrice, but this file never calls it. Line 2 prints theme registered as soon as the file loads. That is a side effect, so the bundler must keep it. Line 3 only exports formatPrice again for other files. So the question is not "does this line look unused?" The question is "if the bundler leaves this out, will the user see a difference?"
Real files do this kind of work too. A file might register a custom HTML element, add a global style, or add a translation for a language. Each of these is a good reason to keep a file even when none of its names are used. If such a file says sideEffects: false, the promise is wrong. The bug may show up only in the production build, where tree shaking runs.
// package.json
{
"sideEffects": false
}
// app.js
import { formatPrice } from "price-tools";
console.log(formatPrice(3));Line 3 says the package's files are pure: they do no work when loaded. Line 7 imports formatPrice from the package, and line 8 calls it. Because the code uses formatPrice, the bundler must keep it. The promise does not remove used functions. It also says nothing about what a function does when you call it.
Now imagine you delete line 8, so nothing uses formatPrice. Then the bundler can leave the whole import out, because the package promised that was safe. What if the package secretly added a style when it loaded? Then its promise was false. The missing style is a bug in the package, not in the bundler.
Four bundler words are easy to mix up. Each row below names one word, says what it is about, and says the question it answers.
| Word | What is it about? | What question does it answer? |
|---|---|---|
| Module graph | Every source file the entry reaches through its imports. | Which files does the bundler need to read? |
| Chunk graph | The output files the bundler writes, and which files go in each. | Which code arrives at the start, and which arrives later? |
| Tree shaking | Exports nobody uses, and other code that is safe to leave out. | Can this code be left out of the bundle? |
| HMR | Swapping one changed file into the open page while you develop. | Can this change show up without reloading the whole page? |
Be careful with sideEffects. Suppose a file that adds a stylesheet or registers code is marked pure by mistake. Then the production build can remove work your app needs. Write tests that check what the app does, not only how big the files are.
Chunk graphs and dynamic imports
The module graph says which file imports which. A chunk graph says which files go together into each output file, or chunk. There are two kinds of import:
- A normal
importat the top of a file is a static import. Its file usually goes into the first chunk, the one the page loads at the start. - An
import()call is a dynamic import. It loads a file only when that line runs. So the bundler can put that file in a separate chunk that loads later.
async function openSettings() {
const settings = await import("./settings.js");
console.log(settings.title);
}
openSettings();Line 2 does not load settings.js when the page starts. It asks for that file only when openSettings runs. The await waits until the file has arrived. Then settings holds an object with the file's exports. Line 3 prints its title, Settings. Line 6 calls openSettings.
const plan = summarizeChunks();
console.log(plan.initial.join(","));
console.log(plan.later.join(","));This code uses a small model of a bundler, written for this lesson. Line 1 asks the model for a plan. Line 2 prints the first group, main.js,cart.js. Line 3 prints the later group, settings.js. Nothing is downloaded here. The model only shows the grouping the bundler decides on before the browser asks for any file.
const imports = {
"main.js": ["cart.js", "settings.js"],
"cart.js": ["price.js"],
"price.js": [],
"settings.js": [],
};
const dynamic = new Set(["settings.js"]);
const plan = planChunkGraph(imports, dynamic, "main.js");
console.log(plan.initial.join(","));
console.log(plan.later.join(","));Lines 1 to 6 list four files and what each one imports. Line 7 marks only settings.js as a dynamic import. Line 9 asks the model to plan the chunks, starting at main.js. Line 10 prints main.js,cart.js,price.js. Line 11 prints settings.js.
cart.js and price.js in the first chunk with main.js. The dynamic import() puts settings.js in a chunk that loads later.The model keeps adding normal imports to the group it is building. It starts a new group only at a file marked as dynamic. A real bundler does more than this small model. For example, it can share one file between two chunks. But the model shows the main idea: a chunk graph is about which files arrive together, not about the order of your source files.
Sometimes two chunks both need the same file. A real bundler can then:
- put that file in its own shared chunk,
- copy a very small file into both chunks,
- or choose something else, based on its settings.
So one import() does not tell you how many files the build will write. Look at the output files. Then measure two things: how fast the first screen shows, and how fast the later feature opens. The graph explains why the bundler chose a split. Only measuring tells you whether the split helped.
Many bundlers add a content hash to each chunk's file name, such as settings.3f9a1c.js. The hash is a short code made from the file's contents. It lets the browser keep a file in its cache for a long time. When a chunk changes, its hash and its URL change too. A chunk that points to the changed chunk may get a new hash as well. The details depend on the bundler and its settings. So check the output, and don't assume that only the file you edited changed.
Splitting code has a cost. A smaller first chunk can show the first screen sooner. But each later chunk is one more download, and a feature has to wait for it. The dynamic import and loading performance lessons show how to make these choices in everyday work.
The runtime and its cache
When the output has more than one chunk, the bundler adds a little code of its own, called the runtime. It runs in the browser next to your code. It has three jobs:
- find each module's code by its name or ID,
- remember each module's exports after it runs once,
- download a chunk when a dynamic import needs it.
const cache = new Map();
const require = (name) => cache.get(name) ?? cache.set(name, { name }).get(name);
console.log(require("cart") === require("cart"));Line 1 makes an empty cache. A Map stores values under keys, here module names. Line 2 defines require. It works in two steps:
cache.get(name)looks for a stored object. If one is there,requirereturns it.- If nothing is stored,
??runs the right side. It stores a new object withcache.setand then returns it.
Line 3 calls require("cart") twice. The first call stores an object. The second call gets that same object back, so === gives true.
const result = runtimeFactoryModel();
console.log(result.sameExports);
console.log(result.factoryCalls);
console.log(result.cached.join(","));This code uses a small runtime model written for this lesson. In the model, each module has a factory: a function that runs the module's code and returns its exports. The model asks for cart twice and counts how often the factory runs.
- Line 2 prints
true, because both requests get the same exports object. - Line 3 prints
1, because the factory ran only once. - Line 4 prints
cart, the name stored in the cache.
So the cache is not there to save a few bytes. It makes sure every file that imports cart gets the very same module, and that the module's code runs only once.
Real runtimes do more. They also work out where chunk files live, give each chunk an ID, handle a chunk that fails to load, and use promises to wait for downloads. The generated names differ between bundlers and settings. You don't need to read them. Remember the three main jobs instead:
- find a module by its name,
- run its code only once,
- load later chunks when
import()asks for them.
Hot module replacement
Hot module replacement, or HMR, is a feature you use only while you develop. You run a dev server, a small local server that serves your files while you work. HMR then works like this:
- You save a file.
- The dev server sends the changed file to the open page.
- The runtime swaps the old module for the new one.
- If some file said it can take this update, its callback function runs. That file is called the boundary for the update.
if (import.meta.hot) {
import.meta.hot.accept("./price.js", (changed) => {
console.log(changed.price);
});
}if (module.hot) {
module.hot.accept("./price.js", () => {
console.log("price module replaced");
});
}Vite and webpack are two popular bundlers. In both examples:
- Line 1 checks that HMR is on. The
hotobject exists only on a dev server. - Line 2 says "this file can take updates to
price.js". - Line 3 runs after the new
price.jshas replaced the old one.
You can't run these examples on this page, because only a dev server provides the hot object. Think of a buffet: the staff swap one dish without closing the whole restaurant. In the same way, the page keeps running, and what you typed into it can stay, when the update is accepted.
HMR can't handle every change safely. After a change, it may:
- also update the files that import the changed file,
- reload the whole page,
- or need help from your framework to keep the page's data.
Visitors to your real site never use HMR. They get the normal chunks from the production build.
Practical bundle decisions
When the output surprises you, turn it into one small question you can check. For example:
- Is an export from a library still in the output, even though nothing uses it?
- Did a dynamic import make a chunk that loads later?
- Is a file that registers code missing from the output?
Look at the output files and at the browser's Network panel first. Change the bundler settings only after that.
- Read
import { total }from the entry file. - Rename one of two variables called
price. - Download a chunk after
import()runs. - Return the same exports object to a second
require. - Send a changed file from the dev server to the page.
- Run the callback of a file that accepts an HMR update.
Sort each action: does it happen at build time, in the browser while the page runs, or during a development update? Then read the explanation.
Don't split every component into its own chunk. Start with a part of the app that people open less often, such as a settings panel, an editor, or a report. Then measure two things: how fast the page first loads, and how fast that part opens. A smaller first download doesn't help if a common click now waits for a lot of code.
Import from the exact file when the output surprises you
A barrel file is an index.js file that only exports things from other files, so you can import them all from one place.
import { formatPrice } from "./prices/index.js";
console.log(formatPrice(3));import { formatPrice } from "./prices/format-price.js";
console.log(formatPrice(3));Line 1 of both examples imports formatPrice. The first goes through prices/index.js. That file may also export or load other files you don't need. The second names the exact file, format-price.js. Modern bundlers handle most barrel files well. But the exact path is easier to check when the barrel also imports a file that registers code.
This doesn't mean you should never use barrel files. Keep one when it gives your library a clean list of exports. Let the bundle report decide. If the barrel has no side effects and its exports are plain export statements, the build may already be as small as it can be.
Common misconceptions
- “Bundling means one file.” A build can write many chunks.
- “Tree shaking removes every line that looks unused.” It must keep any file that does visible work when it loads.
- “A dynamic import always makes the page faster.” It moves work to later, and a click may then have to wait for a download.
- “HMR is a cache for the live site.” HMR is only for development. It swaps changed files while you work.
- “Scope hoisting just pastes files together.” It must keep the code working the same way, and it renames variables whose names clash.
Practice exercises
Read the graph walk and type exactly what it prints.
const modules = { "main.js": ["cart.js"], "cart.js": ["price.js"], "price.js": [] };
const seen = new Set();
const visit = (name) => { if (seen.has(name)) return; seen.add(name); modules[name].forEach(visit); };
visit("main.js");
console.log([...seen].join(","));It prints main.js,cart.js,price.js. visit adds each file to the Set once, then visits the files it imports.
Two files are joined into one scope. Both declare price. What clear new name can the bundle give the price from cart.js?
const cart_js_price = 5;The new name, cart_js_price, can't clash with the first price once both files share one scope.
No file imports any name from a module. But the module does visible work at its top level when it loads. What should the bundle do with it?
Keep the module. If its sideEffects setting is correct, the bundler won't delete work that runs when the file loads.
Predict what this tiny dynamic-import model prints.
const dynamicImports = ["settings.js"];
console.log(dynamicImports.length === 1 ? "one async chunk" : "main only");It prints one async chunk. The list has exactly one file, so dynamicImports.length === 1 is true. An async chunk is a chunk that loads later.
Predict whether two requests for the same module get the same object.
const cache = new Map();
const require = (name) => cache.get(name) ?? cache.set(name, { name }).get(name);
console.log(require("cart") === require("cart"));It prints true. Both calls return the same object from the cache.
Your dashboard is slow the first time someone opens it. Before you add dynamic imports, what should you measure?
Measure loading performance. Record the downloads in the Network panel, and check how fast real users (or a realistic test) can load the page and use the feature. Then decide where to split.
Check your understanding
For each question, first ask when the action happens: while the bundler builds, while the page runs in the browser, or during a development update with HMR.
Question 1 of 7Where does a bundler's module graph start?
Choose an answer to see the explanation.
Question 2 of 7What does this small graph program print?
Read the code, then predictconst names = ["main.js", "cart.js", "price.js"]; console.log(names.join(","));Choose an answer to see the explanation.
Question 3 of 7Why might scope hoisting rename a variable?
Choose an answer to see the explanation.
Question 4 of 7What does
sideEffects: falsepromise, when the promise is true?Choose an answer to see the explanation.
Question 5 of 7What usually puts a file in a chunk that loads later?
Choose an answer to see the explanation.
Question 6 of 7What does this cache check print?
Read the code, then predictconst cache = new Map(); const get = (name) => cache.get(name) ?? cache.set(name, { name }).get(name); console.log(get("cart") === get("cart"));Choose an answer to see the explanation.
Question 7 of 7What does HMR try to keep when a file it accepts changes?
Choose an answer to see the explanation.
Key takeaways
- The entry file and its normal imports make up the module graph.
- Scope hoisting puts several files into one scope and renames variables whose names clash.
- Tree shaking needs to know which exports are used and which files have side effects.
- A dynamic
import()starts a chunk that the runtime loads later. - During development, HMR swaps a changed file when some file accepts the update.
Remember the one-liner.
A bundler reads which files import which, groups them into chunks, and adds a small runtime to load those chunks.
Coming next: Agents, agent clusters & realms. That lesson shows how the JavaScript specification describes threads, shared memory, and global objects.