---
title: 'Module Bundling'
source: 'https://academia.sh/en/courses/rendering-strategies/module-bundling'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

```js
// 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.
