Lesson 08 / 19
Module Bundling
The source tree turning into an executable output; extracting the dependency graph from entry points, evaluation order, producing output chunks by usage signature, and the runtime inside a chunk.
Contents
The strategy has been chosen. Which page of the North Slope Measurement Station site gets produced where is settled: the landing page at build time, the measurement list on the server, the operator’s panel on the client, the routing decision at the edge. These decisions shared one assumption, and that assumption has not been met yet. Every strategy requires an executable output: build-time generation requires a program ready to stamp out pages; server-side rendering requires a module ready to load on the server; client-side rendering requires a script ready to ship to the browser.
The source tree is none of these. It holds dozens of small files, import relationships between them, and specifiers the target environment cannot resolve on its own. This lesson’s question is: from the dependency relationship between source files, which output chunks get produced, and by what criterion?
Source Layout and Output Layout Are Not the Same Thing
The Bundler Concept lesson in the Modules, Tooling and the Ecosystem course established this distinction: source layout is designed for readability, output layout for the target environment’s constraints. That lesson produced a single output from a single entry. The question changes the moment a rendering strategy selects more than one target. If the same source tree produces both a script bound for the browser and a module meant to run on the server, the two outputs share modules. How that overlap is handled determines both output size and caching behavior.
Module bundling is the work of finding the set of modules reachable from the entry points and splitting that set into as many files as the target requires. It contains two separate decisions: extracting who depends on whom in the graph, and splitting that graph into chunks.
The Application’s Source Tree
The same source tree is used throughout the course. The block below produces it.
mkdir -p source cat > source/units.js <<'EOF' export const TEMP_MIN = -60; export const TEMP_MAX = 60; export const isWithinRange = (c) => c >= TEMP_MIN && c <= TEMP_MAX; export const fahrenheit = (c) => c * 9 / 5 + 32; EOF cat > source/date.js <<'EOF' const two = (s) => String(s).padStart(2, "0"); export const hourMinute = (d) => two(new Date(d).getUTCHours()) + ":" + two(new Date(d).getUTCMinutes()); export const dayKey = (d) => new Date(d).toISOString().slice(0, 10); EOF cat > source/format.js <<'EOF' import { hourMinute } from "./date.js"; import { isWithinRange, fahrenheit } from "./units.js"; export function measurementLine(m) { const flag = isWithinRange(m.temperature) ? "" : " (out of range)"; return hourMinute(m.timestamp) + " " + m.temperature.toFixed(1) + " C %" + m.humidity + flag; } export const dualUnit = (m) => m.temperature.toFixed(1) + " C / " + fahrenheit(m.temperature).toFixed(1) + " F"; EOF cat > source/template.js <<'EOF' import { measurementLine } from "./format.js"; export const list = (measurements) => "<ul>" + measurements.map((m) => "<li>" + measurementLine(m) + "</li>").join("") + "</ul>"; export const heading = (station) => "<h1>" + station + "</h1>"; EOF cat > source/chart.js <<'EOF' import { TEMP_MIN, TEMP_MAX } from "./units.js"; const scale = (v, length) => ((v - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * length; export function temperaturePath(measurements, width, height) { const step = measurements.length > 1 ? width / (measurements.length - 1) : 0; return measurements .map((m, i) => (i ? "L" : "M") + (i * step).toFixed(1) + " " + (height - scale(m.temperature, height)).toFixed(1)) .join(" "); } EOF cat > source/archive.js <<'EOF' import { dayKey } from "./date.js"; import { list } from "./template.js"; export function splitByDay(measurements) { const bucket = new Map(); for (const m of measurements) { const d = dayKey(m.timestamp); bucket.set(d, [...(bucket.get(d) ?? []), m]); } return bucket; } export const archivePage = (day, measurements) => "<h2>" + day + "</h2>" + list(measurements); EOF cat > source/entry-browser.js <<'EOF' import { list, heading } from "./template.js"; import { temperaturePath } from "./chart.js"; export function start(root, station, measurements) { root.innerHTML = heading(station) + list(measurements); return temperaturePath(measurements, 320, 120); } EOF cat > source/entry-server.js <<'EOF' import { list, heading } from "./template.js"; import { splitByDay, archivePage } from "./archive.js"; export function buildPages(station, measurements) { const daily = [...splitByDay(measurements)].map(([d, l]) => archivePage(d, l)); return { live: heading(station) + list(measurements), archive: daily }; } EOF
Eight modules, two entry points. The browser entry paints the screen and produces the temperature chart; the server entry produces the same list along with the daily archive pages. The format, template, units, and date modules are used by both.
Extracting the Graph
The graph is found by a traversal starting from the entry points. Each module is a node, each import a directed edge. The Breadth-First Search lesson in the Data Structures course runs here over the file system: a path comes off the queue, its source is read, its specifiers are resolved, and the neighbors found are pushed onto the queue.
// graph.mjs — dependency graph and output chunks from the source tree. import { readFileSync, statSync } from "node:fs"; import { dirname, join, relative } from "node:path"; const ENTRIES = ["source/entry-browser.js", "source/entry-server.js"]; const IMPORT_RE = /^\s*(?:import|export)[^"']*from\s*["']([^"']+)["']/gm; function extractGraph(entries) { const graph = new Map(); // path -> { size, neighbors[] } const queue = [...entries]; while (queue.length > 0) { const filePath = queue.shift(); if (graph.has(filePath)) continue; const source = readFileSync(filePath, "utf8"); const neighbors = [...source.matchAll(IMPORT_RE)] .map(([, token]) => relative(".", join(dirname(filePath), token))); graph.set(filePath, { size: statSync(filePath).size, neighbors }); queue.push(...neighbors); } return graph; } function closure(graph, root) { // all nodes reachable from root const visited = new Set(), stack = [root]; while (stack.length > 0) { const filePath = stack.pop(); if (visited.has(filePath)) continue; visited.add(filePath); stack.push(...graph.get(filePath).neighbors); } return visited; } function evaluationOrder(graph, root) { // post-order traversal: dependency first const order = [], visited = new Set(); (function visit(filePath) { if (visited.has(filePath)) return; visited.add(filePath); for (const n of graph.get(filePath).neighbors) visit(n); order.push(filePath); })(root); return order; } const graph = extractGraph(ENTRIES); const name = (p) => p.replace("source/", ""); const edgeCount = [...graph.values()].reduce((t, d) => t + d.neighbors.length, 0); console.log("-- graph --"); console.log("node:", graph.size, " edge:", edgeCount); for (const [filePath, d] of [...graph].sort()) { console.log(" " + name(filePath).padEnd(20) + String(d.size).padStart(5) + " B -> " + (d.neighbors.map(name).join(", ") || "(leaf)")); } console.log("-- evaluation order (browser entry) --"); console.log(" " + evaluationOrder(graph, ENTRIES[0]).map(name).join(" -> ")); const closures = new Map(ENTRIES.map((e) => [e, closure(graph, e)])); console.log("-- entry closures --"); for (const [e, c] of closures) { const bytes = [...c].reduce((t, p) => t + graph.get(p).size, 0); console.log(" " + name(e).padEnd(20) + String(c.size).padStart(2) + " module " + String(bytes).padStart(5) + " B"); } // Signature: the set of entries that request the module. Same signature = same output chunk. const signature = new Map(); for (const filePath of graph.keys()) { const requesters = ENTRIES.filter((e) => closures.get(e).has(filePath)); const key = requesters.map(name).join(" + "); if (!signature.has(key)) signature.set(key, []); signature.get(key).push(filePath); } console.log("-- output chunks --"); let totalChunks = 0; for (const [key, modules] of [...signature].sort((a, b) => b[0].length - a[0].length)) { const bytes = modules.reduce((t, p) => t + graph.get(p).size, 0); totalChunks += bytes; console.log(" requested by: " + key); console.log(" " + String(bytes).padStart(5) + " B " + modules.map(name).sort().join(", ")); } const sourceTotal = [...graph.values()].reduce((t, d) => t + d.size, 0); console.log("-- total --"); console.log(" source:", sourceTotal, "B chunks:", totalChunks, "B"); console.log(" no module copied:", sourceTotal === totalChunks);
$ node graph.mjs
-- graph --
node: 8 edge: 10
archive.js 383 B -> date.js, template.js
chart.js 439 B -> units.js
date.js 222 B -> (leaf)
entry-browser.js 257 B -> template.js, chart.js
entry-server.js 314 B -> template.js, archive.js
format.js 417 B -> date.js, units.js
template.js 244 B -> format.js
units.js 174 B -> (leaf)
-- evaluation order (browser entry) --
date.js -> units.js -> format.js -> template.js -> chart.js -> entry-browser.js
-- entry closures --
entry-browser.js 6 module 1753 B
entry-server.js 6 module 1754 B
-- output chunks --
requested by: entry-browser.js + entry-server.js
1057 B date.js, format.js, template.js, units.js
requested by: entry-browser.js
696 B chart.js, entry-browser.js
requested by: entry-server.js
697 B archive.js, entry-server.js
-- total --
source: 2450 B chunks: 2450 B
no module copied: true
The byte counts are the files’ real sizes; these values come out when the source files are produced exactly as in the block above. Even a one-line edit changes the numbers.
Evaluation Order
The graph’s first use is ordering. A module’s body must run after its dependencies’
bodies have run; otherwise it reaches for a binding that is not yet defined. Post-order
traversal gives this order: date.js and units.js come first, the entry point comes
last. This is the Topological Sort lesson from the Data Structures course applied to the
graph.
The ordering shows up in the output in two ways. In a chunk written to a single file, the module bodies are laid out in this order. In output split across multiple files, the order turns into a loading order for the files: a chunk cannot run until the chunk it depends on has been evaluated.
If the graph has a cycle, post-order traversal does not give a single correct order. The module systems topic’s discussion of circular dependencies applies here as well: the bundler does not break the cycle, it only picks an order, and the order it picks determines which binding gets read early.
The Entry Point Set
How many entry points a build has is a direct consequence of the rendering strategy decision. Code running in the browser needs one entry, code running on the server a separate entry, a module running at the edge a third entry. Each entry has its own closure, and the closures intersect: in the example, four of the six modules in each entry’s closure are shared.
Entries differ not only in scope but in target. Two outputs produced from the same source are written in different formats: the server output as a module the runtime can load directly, the browser output as a script to attach to the document. Which dependencies get left external also depends on the target — on the server entry, modules the runtime supplies itself are not pulled into the graph; on the browser entry no such source exists, and everything must go into the output.
This means a single file in the source tree can appear in two different forms across two different outputs. That shared modules must work under both targets is a constraint placed on the source layout: the shared layer holds only environment-independent code.
The Splitting Criterion
Shared modules can be handled two ways. They can be copied into each entry’s output; then the same code sits twice, in two files. Or they can be placed in a separate chunk and loaded from both entries; then one more file is produced.
The decision rests on a single piece of information readable off the graph: which entries request a module. This set is called the module’s usage signature. Modules with the same signature are needed together, and so can be placed together. Different signatures produce different chunks.
This is why three chunks show up in the output: the four modules both entries request sit in one shared chunk, the two modules only the browser requests sit in one chunk, the two modules only the server requests sit in a third chunk. The check on the last line is the criterion’s proof: if the chunks’ total equals the source’s total, no module has been copied into two chunks.
The Code Splitting and Lazy Loading lesson in the Application Architecture course used the same criterion for route boundaries; there, the set that made up the signature was routes. The criterion is the same, the source that produces the signature differs: entries in this lesson, routes there.
What’s Inside a Chunk
A chunk is not just module bodies written one after another. Each module’s own scope must be preserved, its exports must be visible to other modules, and a module must be evaluated once even if it is imported more than once. Three things inside the chunk provide this: module wrappers, a registry that guarantees single evaluation, and a mapping that translates specifiers into output keys. The Bundler Concept lesson in the Modules, Tooling and the Ecosystem course built this structure working end to end.
The existence of a shared chunk adds one more thing: a cross-chunk export surface. If the
browser chunk uses two names from template.js, the shared chunk must give those names
out. A chunk boundary is therefore not only a file boundary but also an interface
boundary: every name that crosses it must stay visible in the output, and cannot be
renamed away or eliminated.
Scope hoisting is bounded at this point. Modules inside the same chunk can be melted into a single scope; names that cross a chunk boundary cannot be melted. Increasing the number of chunks therefore affects not only the request count but also the range of transforms that can be applied.
What the Graph Does Not Say
The graph built here comes only from static imports, and that is one part of a real build’s input.
The specifiers were found with a regular expression. An import-looking string inside a template literal matches just as well as a line inside a comment does. Real tools parse the source and work over the abstract syntax tree; extension completion, directory entry points, bare-specifier resolution, and resolving conditional entries are also that layer’s job.
Static import is not the only kind of edge that enters the graph. Dynamic import is an edge too, but its timing differs; style files, images, and fonts produce edges as well. Each of these changes how chunks form, and the next three lessons add them in turn.
Last, the graph says a module’s entirety is needed; it does not say which name inside it
is used. units.js in the shared chunk exports four names, and two of them might never
be used. Seeing that distinction requires bringing the graph down from module level to
name level.
Summary
- Module bundling is two decisions: extracting the set of modules reachable from the entry points, and splitting that set into output chunks.
- The dependency graph is built by traversal from the entry points; post-order traversal gives the evaluation order that places dependencies before what depends on them.
- The number of entry points comes from the rendering strategy decision; each entry has its own closure, its own output format, and its own external-dependency boundary.
- Modules are split into chunks by usage signature — the set of entries that request them; the chunks’ total size equaling the source’s total is the proof that no module was copied.
- A chunk boundary is also an interface boundary: names that cross it must be preserved in the output, and scope hoisting is bounded to inside a chunk.
Next Step
The chunks in this lesson were all born from entry points, and all of them were needed
at first load. But not every part of an application is needed at the same time: the
station operator’s panel is wanted only when that panel opens, the export screen only
when that button is pressed. The source has a syntax that marks this distinction — an
import() call adds an edge to the graph but separates it in time. The next lesson takes
up this boundary: how chunks born from dynamic import are computed, how what downloads
at first load is separated from what downloads later, and how the point at which
increasing the chunk count turns into a loss is measured.
To keep your progress and take notes, Log in
My notes
Log in to take notes.