Module Bundling Concepts

Course 1 · Ch 2
Module Bundling Concepts
The entry-point and dependency-graph model every bundler shares, and the deep difference between CommonJS and ES Modules

build-tooling1-1's own import example let a bundler figure out load order automatically — this chapter explains exactly how. Every bundler, regardless of brand, works from the same two ideas: an entry point, and the dependency graph it grows from.

The Entry Point — Where a Bundler Starts

You tell a bundler one (or a few) starting file — the entry point. The bundler then follows every import statement it finds, recursively, discovering the full set of files the application actually needs. Nothing outside that reachable set gets included at all — a detail worth remembering now, since it's exactly what build-tooling1-8's own tree-shaking chapter builds on directly.

The Dependency Graph

Each file is a node; each import is an edge pointing at a dependency. A small example:

// app.js (entry point) import { formatPrice } from './utils.js'; import { fetchOrder } from './api.js'; // api.js import { formatPrice } from './utils.js'; export function fetchOrder() { /* ... */ } // utils.js export function formatPrice(n) { /* ... */ }

Starting from app.js, the bundler discovers api.js and utils.js, and — since api.js itself imports utils.js — recognizes utils.js only needs to be included once in the final output, even though two different files reference it.

A familiar shape, from a very different course
Building the full graph before generating any output is structurally the same idea as assembly1-9's own two-pass assembler: Pass 1 scans the whole file to build a complete symbol table before Pass 2 ever generates real machine code. A bundler does the same thing one level up — discover the entire graph first, then emit the actual bundled output — for the same underlying reason: you can't safely resolve or optimize something you haven't fully seen yet.

CommonJS vs. ES Modules — Not Just a Syntax Difference

build-tooling1-1 already contrasted CommonJS's require() against ESM's import. The real difference goes deeper than spelling:

// CommonJS — require() can be dynamic, even conditional if (useMetric) { const converter = require('./metric.js'); } // ES Modules — import MUST be top-level and static import { convert } from './metric.js'; // no condition, no computed path — ever

CommonJS's require() is resolved dynamically, at runtime — it can sit inside an if statement, use a computed string, anything ordinary JavaScript can do. ES Modules' import/export are static — required to appear at a file's top level, with a fixed, literal module specifier, never conditional or computed. That constraint is deliberate: it means a tool can determine a file's entire set of dependencies just by parsing the text, without ever executing a single line of the program.

CommonJSES Modules
Resolution timingDynamic — at runtimeStatic — determinable by parsing alone
Can appear conditionally?Yes — inside if/for/functionsNo — top-level only
Module specifierCan be a computed/dynamic stringMust be a fixed, literal string
Full graph knowable without running the code?Not reliablyYes, always

Why This Matters Beyond Syntax

A dependency graph built from ESM's static imports can be safely trimmed of any branch the entry point never actually reaches — build-tooling1-8's own tree-shaking depends entirely on this. A graph built from CommonJS can't be trimmed with the same confidence: since a require() call might be hidden behind a runtime condition, "unused" code identified by static analysis alone risks removing something a conditional path genuinely needed. This is exactly why modern npm packages increasingly ship dual builds — both a CommonJS version for older Node-style consumers, and a separate ES Module version specifically so modern bundlers can analyze and optimize it properly.

Mixing the two is a real, common source of build errors
A project pulling in some packages that only ship CommonJS and others that only ship ESM can run into genuine interop friction — mismatched `default` exports, or a bundler needing extra configuration to bridge the two systems. Fully debugging CommonJS/ESM interop issues is beyond this course's own scope, but knowing this is a real, commonly-hit category of build error — not a sign something is fundamentally broken — is worth having going in.

Coding Challenges

Challenge 1

Using this chapter's own three-file example (app.js, api.js, utils.js), explain why utils.js only appears once in the final bundled output even though two different files import it.

📄 View solution
Challenge 2

Using this chapter's own dynamic-require example, explain why a bundler cannot reliably determine CommonJS's full dependency graph just by parsing the file's text, and explain why this determines whether tree-shaking is safe to perform.

📄 View solution
Challenge 3

Explain the parallel this chapter draws between building a dependency graph and assembly1-9's own two-pass assembler algorithm — what's the structurally similar step in each, and why does each one need that step to happen first?

📄 View solution

Chapter 2 Quick Reference

  • Every bundler works from an entry point, following imports recursively to discover the full dependency graph
  • Files reachable more than once (shared dependencies) are only included once in the final output
  • Building the graph before generating output mirrors assembly1-9's own two-pass assembler — discover everything first, then generate
  • CommonJS — require() is dynamic/conditional, resolved at runtime; the full graph can't always be known without running the code
  • ES Modules — import/export are static and top-level-only; the full graph is always knowable from parsing alone
  • This static-vs-dynamic distinction is exactly why tree-shaking (Ch.8) works reliably with ESM but not with CommonJS
  • Many modern npm packages ship both a CommonJS and an ESM build — real, current practice, not a historical footnote
  • Next chapter: Webpack — The Original Do-Everything Bundler