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.
- 01Trace build-time decisionsFollow an entry file through imports, export use, side-effect rules, and chunk boundaries.
- 02Explain emitted codeRecognize scope hoisting, a small module runtime, caching, and a dynamic chunk loader in a build output.
- 03Debug development updatesExplain how an HMR boundary accepts a changed module without necessarily resetting page state.
A module system rebuilt early
A bundler is a build tool that reads an entry module, follows its imports, and writes files the browser can load. It does this before a visitor opens the page. The browser still runs JavaScript modules or emitted runtime code, but the bundler has already made many loading decisions.
Bundling turns a source module graph into one or more output chunks. The build can remove unused code, combine safe module scopes, and add a small runtime for chunks that load later.
This goes deeper than the everyday bundlers lesson. It follows the module records ideas from module records and continues from module loading in Node.js.
Building the module graph
The entry file is the first file a bundler packs. Each static import is an edge to another file. Reading those edges repeatedly creates a module graph: a list of modules and the relationships between them.
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(","));Line 1 starts with an empty graph. Line 2 puts main.js on the to-do list. Lines 3 through 7 take one module at a time, keep it once, and add its imports. It prints main.js,cart.js,price.js.
A guided replay of the lesson's graph builder. It is a model of build-time work, not a debugger inside a bundler.
script
console.log(graph.join(","));Start with your trip checklist, pack each item it mentions, and then pack what those items need. A visited-item list stops you from packing the same charger twice.
- In real life: A checklist starts the packing
- In JavaScript: An entry file starts the graph
- In real life: Each item points to another need
- In JavaScript: An import points to a dependency
- In real life: A packed item is not packed twice
- In JavaScript: A visited module is not revisited
- In real life: Unused things stay home
- In JavaScript: Unused code may be shaken out
Where the analogy stops: Real modules can have conditions, aliases, and cycles; the analogy only explains the reachability walk.
A graph is not necessarily the emitted order. Bundlers parse and link the source first, then choose an order that respects dependencies. That distinction matters when a cycle, a dynamic import, or a side effect appears.
Scope hoisting
After the build understands the graph, it can sometimes place several modules in one JavaScript scope. Scope hoisting is that optimization. It avoids a wrapper function or module lookup for each small module when semantics allow it.
// before: both files say const price// const price = 3;// const price = 5; // after scope hoistingconst price = 3;const cart_js_price = 5;console.log(price + cart_js_price);Lines 2 and 3 cannot both be plain top-level declarations in one scope. The emitted version keeps the first price and renames the second one to cart_js_price. Line 8 therefore prints 8.
Instead of putting every small item in its own pouch inside a bag, put them in one bag. If two items have the same label, rewrite one label before closing the bag.
- In real life: Small pouches sit inside one bag
- In JavaScript: Small modules share emitted scope
- In real life: Two labels say price
- In JavaScript: Two bindings have the same name
- In real life: One label is rewritten
- In JavaScript: A compiler renames a collision
Where the analogy stops: Modules have rules about live bindings and evaluation. Hoisting is not simple text pasting.
This does not mean the tool forgets module behavior. It must preserve imports, exports, evaluation order, and names that can be observed. A bundler can choose wrappers when combining scopes would change behavior.
How tree shaking decides
Tree shaking is build-time removal of code that the bundler can prove is unused and safe to omit. The phrase comes from shaking a tree so unused branches fall away, but the proof is about imports, exports, and effects rather than guessing what a person might click.
A replay of a small tree-shaking model. Real bundlers have richer syntax and side-effect analysis.
script
console.log(result.modules.join(","));The replay starts at main.js, marks the imported total export, and follows the imports needed to calculate it. A real tool also has to preserve a module whose top level changes global state, registers a custom element, or starts a listener.
const result = treeShake(modules, "main.js");console.log(result.modules.join(","));main.js, cart.js, price.jsThese modules have an imported export, are the entry, or have declared side effects.
{"main.js":[],"cart.js":["total"],"price.js":["price"]}Only imported names are marked in this small model.
The bundle keeps main.js, cart.js, price.js. Toggle one reason for keeping code, then reset to restore the entry importing total.
Change one toggle at a time. When the entry imports nothing, the model keeps only the entry unless price.js says it has side effects. Reset restores the original import of total.
Side effects and package metadata
A side effect is observable work that happens just by evaluating a module, such as changing a global, adding a style rule, or registering code with a host API. An exported function is not itself a side effect until somebody calls it; a top-level call can be.
In a package package.json, the sideEffects field can tell a compatible bundler whether files are safe to drop when no exports are used. A truthful sideEffects: false is a promise about importing files, not a promise that their exported functions do nothing.
import { formatPrice } from "./price.js";
console.log("theme registered");
export { formatPrice };Line 1 imports a name, but this tiny source does not read it. Line 2 runs as soon as the module evaluates, so a bundler must keep that work. Line 3 only re-exports the imported name. The important question is not whether a line looks unused; it is whether removing evaluation can change what the user sees.
For example, a module may register a custom element, attach a global style, or add a locale. Those jobs can be correct reasons to retain an otherwise unused import. Marking such a file as side-effect-free is a broken promise, and the broken promise can appear only in a production bundle.
// package.json
{
"sideEffects": false
}
// app.js
import { formatPrice } from "price-tools";
console.log(formatPrice(3));Line 3 says the package can be treated as pure when an import from it is unused. Line 8 uses formatPrice, so the bundler must still keep the code that creates that export. The metadata does not make the function disappear, and it does not say that calling the function has no effect.
Now change only one fact in your head: remove line 8 and remove the import's only use. A compatible bundler can omit the unused import because the package made that promise. If the package secretly registered a style while evaluating, the promise was false and the missing style is the package author's bug.
| Idea | What it describes | Question it answers |
|---|---|---|
| Module graph | Every statically reachable source module | Which files must be understood at build time |
| Chunk graph | Groups of emitted files | Which bytes arrive together or later |
| Tree shaking | Unused exports and safely removable code | Whether code can be omitted |
| HMR | A development-time update boundary | Whether one changed module can update without a full reload |
Be conservative with metadata. If a stylesheet import or registration module is accidentally marked pure, a production build can remove work your app relied on. Tests should cover the behavior, not only the file size.
Chunk graphs and dynamic imports
A module graph says which source modules depend on which others. A chunk graph groups those modules into output files. Static imports commonly join the initial path; a dynamic import() gives the build a later loading boundary.
async function openSettings() {
const settings = await import("./settings.js");
console.log(settings.title);
}
openSettings();Line 2 does not load settings while the initial graph is evaluated. It asks the runtime for that module when openSettings runs. Line 3 waits for its namespace object and prints its title after the chunk arrives.
const plan = summarizeChunks();
console.log(plan.initial.join(","));
console.log(plan.later.join(","));Line 1 asks the teaching model for a plan. Line 2 prints the initial group, main.js,cart.js. Line 3 prints the later group, settings.js. This is not a network trace: it only makes the build-time grouping decision visible before a browser runtime fetches anything.
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 through 6 name four source modules. Line 7 labels only settings.js as dynamic. Line 9 calls the graph planner from main.js. The first print is main.js,cart.js,price.js; the second is settings.js.
The planner keeps walking static dependencies in the group it is already building. It switches groups only at the marked dynamic boundary. This is deliberately smaller than a production optimizer, which can share modules between chunks, but it explains why a chunk graph is about delivery groups rather than just source-file order.
A real build can also find a module used by two output groups. It may place that module in a shared chunk, duplicate a very small module, or make another trade-off based on its configuration. Do not infer the final file count from one import(); inspect the emitted plan and then measure the first screen and the later interaction. The graph explains the choice; user-visible loading evidence decides whether that choice helped.
Chunking has a trade-off. Smaller initial chunks can show the first screen sooner, but too many later requests can delay a feature. Read dynamic import and loading performance for the everyday performance decisions.
The runtime and its cache
Output with multiple chunks needs a small runtime. Runtime here means emitted browser code that maps module IDs to factories, remembers evaluated exports, and requests a chunk when a dynamic import needs one.
const cache = new Map();
const require = (name) => cache.get(name) ?? cache.set(name, { name }).get(name);
console.log(require("cart") === require("cart"));Line 1 creates a cache. Line 2 creates an exports object only when the module is absent, then returns the cached object. Line 3 prints true because both calls receive the same object.
const result = runtimeFactoryModel();
console.log(result.sameExports);
console.log(result.factoryCalls);
console.log(result.cached.join(","));Line 1 runs the runtime teaching model. Line 2 prints true because both reads share one exports object. Line 3 prints 1: the factory ran once. Line 4 prints cart, the cache key. This is why a runtime cache is about module identity, not merely saving a few characters.
Real emitted runtimes also manage public paths, chunk IDs, loading failures, and asynchronous promises. Their exact generated names differ by tool and configuration, so read them as an implementation detail after learning the three jobs: locate, evaluate once, and load later chunks.
Hot module replacement
Hot module replacement, or HMR, is a development update protocol. After you save a file, the dev server sends the changed module. The runtime replaces it and reruns a nearby accepting callback when one says the update is safe.
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");
});
}Both sketches name price.js as the update boundary. They are intentionally not runnable in the article sandbox because the dev server supplies the hot object. In plain words: change one dish at a buffet without closing the whole restaurant; page state can remain when the changed code is accepted.
HMR cannot safely preserve every update. A changed module may invalidate parents, force a reload, or need framework-specific state recovery. Production visitors receive normal deployed chunks, not a permanent HMR connection.
Practical bundle decisions
Use bundler knowledge to form a small, testable question. Is an unused library export still in the output? Did a dynamic import create a late chunk? Is a side-effectful registration file missing? Inspect the emitted files and a network trace before changing configuration.
- Read
import { total }from an entry file. - Rename one colliding top-level
pricebinding. - Fetch a chunk after
import()is called. - Return the same exports object for a second require.
- Send a changed source module from the dev server.
- Run an accepting HMR callback.
Sort each action into build time, browser runtime, or a development update. Then read the explanation.
Do not split every component automatically. Start with a meaningful boundary such as a settings panel, an editor, or an infrequently opened report. Measure initial loading and the later interaction; a smaller first download is not useful if a common click now waits for too much code.
Prefer a clear import path when output is surprising
import { formatPrice } from "./prices/index.js";
console.log(formatPrice(3));import { formatPrice } from "./prices/format-price.js";
console.log(formatPrice(3));Both lines ask for formatPrice. The first path goes through an index file, which may also re-export or evaluate unrelated modules. The second path states the exact module. Modern bundlers can shake many barrels well, but a direct path is easier to inspect when an index file also imports a registration module.
This is a practical before-and-after, not a rule to ban barrels. Keep a barrel when it is a clean public API. Use the bundle report when deciding: if the index file has no effects and exports are statically known, the build may already be optimal.
Common misconceptions
- “Bundling means one file.” A build can emit many chunks.
- “Tree shaking removes every unused-looking line.” It must preserve possible side effects.
- “A dynamic import always improves performance.” It moves work later and can make an interaction wait.
- “HMR is a production cache.” It is a development update mechanism.
- “Scope hoisting is string concatenation.” It must preserve module semantics and rename collisions safely.
Practice exercises
Read the graph walk and type its exact output.
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. The Set keeps each reachable module once.
What clear name can the bundled version give cart's second price binding?
const cart_js_price = 5;The renamed binding avoids a collision after the scopes are combined.
When no export is imported but a module has observable top-level work, what should the bundle do?
Keep the module. Correct side-effect metadata prevents an optimizer from deleting observable top-level work.
Predict the output of the tiny dynamic-import model.
const dynamicImports = ["settings.js"];
console.log(dynamicImports.length === 1 ? "one async chunk" : "main only");It prints one async chunk. The model has one later-loaded module.
Predict whether two requests for the same module return 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 cached exports object.
Your dashboard is slow on first visit. Before adding dynamic imports, what should you measure?
Measure loading performance. Use a network trace and real-user or representative interaction evidence before changing chunk boundaries.
Check your understanding
Answer by separating build-time graph work, browser runtime work, and development-only HMR work.
Question 1 of 7What starts a bundler's module graph?
Choose an answer to see the explanation.
Question 2 of 7What does the tiny 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 can scope hoisting rename a binding?
Choose an answer to see the explanation.
Question 4 of 7What does
sideEffects: falsepromise when it is truthful?Choose an answer to see the explanation.
Question 5 of 7What commonly creates an async chunk?
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 an accepting HMR boundary try to preserve?
Choose an answer to see the explanation.
Key takeaways
- An entry and static imports form the module graph.
- Scope hoisting can combine safe module scopes and rename collisions.
- Tree shaking needs export-use information and truthful side-effect information.
- Dynamic imports help create chunk boundaries that the runtime loads later.
- HMR swaps changed development modules through accepting boundaries when it can.
Remember the one-liner.
A bundler turns source-module relationships into a planned set of chunks and the small runtime needed to load them.
Coming next: Agents, agent clusters & realms, the specification model for threads, shared memory, and globals.