cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Trace build-time decisionsFollow an entry file through imports, export use, side-effect rules, and chunk boundaries.
  • 02
    Explain emitted codeRecognize scope hoisting, a small module runtime, caching, and a dynamic chunk loader in a build output.
  • 03
    Debug 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.

Definition

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.

A tiny graph walkPop out in the code editor (opens in a new tab)JavaScript
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.

Step through a module graph
Step 0 of 4Ready
Your turn: follow the blue line

A guided replay of the lesson's graph builder. It is a model of build-time work, not a debugger inside a bundler.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
console.log(graph.join(","));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Real-life analogyPacking for a trip

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 and after a name collisionPop out in the code editor (opens in a new tab)JavaScript
// 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.

Real-life analogyOne bag instead of small pouches

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.

Step through tree-shaking decisions
Step 0 of 4Ready
Your turn: follow the blue line

A replay of a small tree-shaking model. Real bundlers have richer syntax and side-effect analysis.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
console.log(result.modules.join(","));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Playground: keep one export or one side effect
Tree-shaking modelJavaScript
const result = treeShake(modules, "main.js");console.log(result.modules.join(","));
Bundle result3 modules
keptmain.js, cart.js, price.js

These modules have an imported export, are the entry, or have declared side effects.

used exports{"main.js":[],"cart.js":["total"],"price.js":["price"]}

Only imported names are marked in this small model.

Try it yourself

The bundle keeps main.js, cart.js, price.js. Toggle one reason for keeping code, then reset to restore the entry importing total.

This teaching model marks imported exports and keeps modules marked as side-effectful. Real bundlers also analyze syntax and package boundaries.

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.

Evaluation can matter without an imported valuePop out in the code editor (opens in a new tab)JavaScript
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.

A package promise applies to unused importsJSON
// 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.

Bundler terms that are easy to confuse
IdeaWhat it describesQuestion it answers
Module graphEvery statically reachable source moduleWhich files must be understood at build time
Chunk graphGroups of emitted filesWhich bytes arrive together or later
Tree shakingUnused exports and safely removable codeWhether code can be omitted
HMRA development-time update boundaryWhether 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.

A dynamic import starts later workPop out in the code editor (opens in a new tab)JavaScript
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.

Plan two output groupsJavaScript
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.

Step through an initial and later chunk planJavaScript
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.

A tiny cached module registryPop out in the code editor (opens in a new tab)JavaScript
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.

Count a factory only onceJavaScript
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.

Vite acceptance sketchJavaScript
if (import.meta.hot) {
  import.meta.hot.accept("./price.js", (changed) => {
    console.log(changed.price);
  });
}
webpack acceptance sketchJavaScript
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.

Where does this action happen?
  • Read import { total } from an entry file.
  • Rename one colliding top-level price binding.
  • 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.
Try it yourself
0 of 6 correct

Sort each action into build time, browser runtime, or a development update. Then read the explanation.

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

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

Before: a barrel importJavaScript
import { formatPrice } from "./prices/index.js";
console.log(formatPrice(3));
After: a direct importJavaScript
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

Exercise 1 · Warm-upPredict the graph

Read the graph walk and type its exact output.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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(","));

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

    Exercise 2 · Warm-upRename a collision

    What clear name can the bundled version give cart's second price binding?

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

      Exercise 3 · PracticeProtect observable work

      When no export is imported but a module has observable top-level work, what should the bundle do?

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

        Exercise 4 · PracticePredict the chunk decision

        Predict the output of the tiny dynamic-import model.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const dynamicImports = ["settings.js"];
        console.log(dynamicImports.length === 1 ? "one async chunk" : "main only");

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

          Exercise 5 · ChallengeRead a runtime cache

          Predict whether two requests for the same module return the same object.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const cache = new Map();
          const require = (name) => cache.get(name) ?? cache.set(name, { name }).get(name);
          console.log(require("cart") === require("cart"));

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

            Exercise 6 · ChallengeApply it to a real app

            Your dashboard is slow on first visit. Before adding dynamic imports, what should you measure?

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

              Check your understanding

              Answer by separating build-time graph work, browser runtime work, and development-only HMR work.

              Bundler internals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What starts a bundler's module graph?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the tiny graph program print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const names = ["main.js", "cart.js", "price.js"];
                console.log(names.join(","));

                Choose an answer to see the explanation.

              3. Question 3 of 7Why can scope hoisting rename a binding?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does sideEffects: false promise when it is truthful?

                Choose an answer to see the explanation.

              5. Question 5 of 7What commonly creates an async chunk?

                Choose an answer to see the explanation.

              6. Question 6 of 7What does this cache check print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              7. 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.

              CompleteFrontend Clear concepts. Working examples.