Lesson 10 / 14
The Bundler Concept
The rationale for producing a single output from a module graph, the three steps of a working bundler, the shape of the output, and how tree shaking is limited by side effects.
Contents
The module systems and package management topics established how code is organized in the source tree and how dependencies are found. The source layout does not have to be the same layout the target environment wants.
A bundler is a class of tool that takes a module graph as input and produces one or a few files that run in the target environment. Rather than describe what the tool does, this lesson builds a small implementation of one; after that, what real tools add on top of this skeleton becomes easy to see.
Why a Single Output
The source layout consists of hundreds of small files. Using this layout directly in the target environment creates three problems.
Per-file cost. Every file loaded over the network is a separate request. Request latency can exceed the time spent transferring bytes. The per-request latency introduced in the How the Internet Works course becomes the dominant cost once file count grows large.
Resolution difference. Bare specifiers are resolved by a lookup rule in the file
system. That rule is not present in every environment; a browser cannot turn a name like
format-helper into a file on its own. A bundler replaces these names with their
resolved forms, so the lookup work described in the package management topic happens
once, at build time, instead of on every load.
Language and format difference. The source may contain syntax the target environment does not understand, or a mix of two different module systems. The output is produced in a single, uniform format.
None of these apply to every project. A library that only ever runs at build time and is never distributed over the network can be used without bundling. Bundling is not a requirement, it is the answer to specific problems.
Three Steps
A bundler’s core is three steps: extract the graph starting from an entry point, wrap each module in a function, and write a small runtime that links the modules together.
Take the call-based version of the measurement library as input:
// file: source/splitting.js const WORD_SPLITTER = /[^\p{L}\p{N}]+/u; exports.splitWords = function (text) { return text.split(WORD_SPLITTER).filter((s) => s.length > 0); };
// file: source/statistics.js exports.averageLength = function (words) { if (words.length === 0) return 0; return words.reduce((t, s) => t + s.length, 0) / words.length; }; exports.longestWord = function (words) { return words.reduce((longest, s) => (s.length > longest.length ? s : longest), ''); };
// file: source/index.js const { splitWords } = require('./splitting.js'); const { longestWord, averageLength } = require('./statistics.js'); exports.measure = function (text) { const words = splitWords(text); return { wordCount: words.length, averageLength: averageLength(words), longest: longestWord(words), }; };
// file: source/entry.js const { measure } = require('./index.js'); console.log(measure('A module carries its own scope. A script runs in the global scope.'));
A bundler turns these four files into a single output:
// file: bundle.mjs import { readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; const REQUIRE_PATTERN = /require\(['"]([^'"]+)['"]\)/g; async function extractGraph(entry) { const modules = new Map(); const queue = [entry]; while (queue.length > 0) { const path = queue.shift(); if (modules.has(path)) continue; const source = await readFile(path, 'utf8'); const map = {}; for (const [, request] of source.matchAll(REQUIRE_PATTERN)) { const resolved = join(dirname(path), request); map[request] = resolved; queue.push(resolved); } modules.set(path, { source, map }); } return modules; } function generate(modules, entry) { const registry = [...modules] .map(([path, m]) => `${JSON.stringify(path)}: [function (require, module, exports) {\n${m.source}}, ${JSON.stringify(m.map)}]`) .join(',\n'); return `(function (registry, entry) { const cache = {}; function run(path) { if (cache[path]) return cache[path].exports; const [factory, map] = registry[path]; const mod = { exports: {} }; cache[path] = mod; factory((request) => run(map[request]), mod, mod.exports); return mod.exports; } run(entry); })({\n${registry}\n}, ${JSON.stringify(entry)}); `; } const entry = 'source/entry.js'; const modules = await extractGraph(entry); const output = generate(modules, entry); await writeFile('bundle.js', output); console.log('module count:', modules.size); console.log('graph:', [...modules.keys()].join(', ')); console.log('output size:', output.length, 'bytes');
$ node bundle.mjs module count: 4 graph: source/entry.js, source/index.js, source/splitting.js, source/statistics.js output size: 1716 bytes
$ node bundle.js
{ wordCount: 13, averageLength: 4, longest: 'carries' }
Four files became one, and the output produced the same result the source did.
The Shape of the Output
The start of the generated file shows the entire runtime:
(function (registry, entry) { const cache = {}; function run(path) { if (cache[path]) return cache[path].exports; const [factory, map] = registry[path]; const mod = { exports: {} }; cache[path] = mod; factory((request) => run(map[request]), mod, mod.exports); return mod.exports; } run(entry); })({ "source/entry.js": [function (require, module, exports) { // file: source/entry.js
The shape is a direct match for what the module systems topic described. Every module is
wrapped in a function — the written-out form of the module wrapper. The cache object
provides the single-evaluation guarantee. The map object turns the specifiers found in
the source into keys in the output; resolution was done at build time, not left to run
time.
The registry object is the graph itself, written out as data: one entry per module,
each holding the wrapped factory and that module’s own specifier map. Nothing in run
needs to know how many modules there are or how they are connected — it only follows
whatever map the current module carries.
Its limits are just as visible, and they mark the difference from real tools. Finding
specifiers with a regular expression is not enough: a require-looking string that
happens to appear inside a string literal matches too. Real tools parse the source and
work over an abstract syntax tree — the structure introduced in the compilation stages
lesson of the How Computers Work course. There is also no extension completion,
directory index resolution, or bare-name lookup here; all of that is a real resolver’s
job.
Scope Hoisting
Wrapping every module in its own function carries a cost: a function object, a closure, and a call for every module. As module count grows, this cost becomes measurable.
Scope hoisting is the transform that merges interlinked modules into a single scope instead of wrapping each in a separate function. Names are renamed to avoid collisions; the result is a single body that runs with no intermediate layer.
The transform can only be applied if the relationships between modules are known at
build time. In the call-based system this information is missing, because a require
call can be computed at run time; in the standard module syntax it is complete. This is
the second concrete payoff of static structure. The renaming step is what makes the
merge safe: two modules can each declare a local variable named separator without
conflict as long as they stay in separate function scopes, so merging them into one
scope has to rewrite every name that would otherwise collide.
The Limits of Tree Shaking
Dropping unused code from the output is not done at all in the example bundler. Its effect is measurable: adding a function to the statistics module that nothing calls
// added to the end of source/statistics.js // An export that nothing calls, but that stays in the module. exports.letterDistribution = function (words) { const counts = new Map(); for (const word of words) { for (const letter of word.toLowerCase()) { counts.set(letter, (counts.get(letter) ?? 0) + 1); } } return [...counts.entries()].sort((a, b) => b[1] - a[1]); };
grows the output while behavior stays the same:
$ node bundle.mjs module count: 4 graph: source/entry.js, source/index.js, source/splitting.js, source/statistics.js output size: 2063 bytes
$ node bundle.js
{ wordCount: 13, averageLength: 4, longest: 'carries' }
Three hundred forty-seven bytes were paid for code that never runs. Real tools can drop this function — but only when three conditions hold.
Usage must be visible. It must be possible to statically trace which name an export is used under and from where. If access is computed, it cannot be traced.
There must be no side effect. If a module’s top level does something independent of its exports — writing to a registry, mutating a built-in object — the whole module cannot be dropped. A tool is cautious with modules that have not declared themselves free of side effects, which is why packages mark side-effect information in their manifest.
Access must be direct. If the entire namespace object is bound to a value and iterated over, which names are used cannot be known.
These conditions place a concrete responsibility on the library author: do no work at the top level, export names directly, and mark side-effect status correctly in the manifest.
Externalized Dependencies
Not every dependency needs to join the output. Modules the runtime provides on its own,
or libraries already present in the target environment, are marked as external: the
bundler leaves them out of the graph and leaves the call as it is. A framework a page
already loads from a shared location, or a runtime module like node:fs, are typical
candidates — bundling them again would only duplicate bytes the environment already
has.
The distinction also holds between a library and an application. An application’s output must carry everything it needs to run. A library’s output should not embed its dependencies; if it does, the consumer carries two copies of the same package, and the duplicate-version problems described in the Dependency Trees and Conflicts lesson show up.
Summary
- A bundler takes a module graph as input and produces output that runs in the target environment; its rationale is per-file network cost, resolution difference, and format difference.
- The core is three steps: extract the graph, wrap each module in a function, write a small runtime that links the modules.
- The output’s cache carries the single-evaluation guarantee; its specifier map carries resolution done at build time.
- Scope hoisting removes the per-module wrapper cost and can only be applied when relationships are known at build time.
- Tree shaking depends on three conditions: traceable usage, freedom from side effects, and direct access. Side-effect information is marked in the manifest.
Next Step
The bundler merged modules, but it assumed the syntax the source was written in would be recognized in the target environment. That assumption does not always hold: the target may not be able to parse the syntax the source uses, or it may not provide the capability it calls for. The next lesson covers adapting the source to the target, and where that adaptation stops.
To keep your progress and take notes, Log in
My notes
Log in to take notes.