---
title: 'The Bundler Concept'
source: 'https://academia.sh/en/courses/javascript-ecosystem/bundler-concept'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

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

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:

```javascript
// 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);
};
```

```javascript
// 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), '');
};
```

```javascript
// 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),
  };
};
```

```javascript
// 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:

```javascript
// 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:

```javascript
(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

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