Concepts — A Minimal Bundler
Phase 5 · Spec area §38. Prerequisites: fw-06.
0. Orientation
The 60-second version
A bundler is an incremental compiler with a cache-invalidation problem. That is the whole
module, and it is the third time you meet that problem in this track — after bi-07 style
invalidation and bi-08 layout caching. The repetition is the point.
Build tooling is where large frontend organisations lose the most time, and where decisions are most often made by folklore. Having built a bundler — even a small one — converts "webpack is slow" into specific claims about graph construction, transform caching, and invalidation.
The two results that change how you argue
1. Tree shaking is unsound without author help, and the fix is a promise.
import './polyfill'; // side effect at module scope
window.registry.push(thing); // side effect
export const unused = 1;
Removing this module because unused is unreferenced deletes the polyfill and the registration. The
bundler cannot know those matter — and it cannot know they don't. Hence "sideEffects": false:
the author promising that importing this module for its exports alone is safe. Fourth instance of
the pattern in this track, after key (fw-02), {passive:true} (bi-10), and contain
(bi-07).
2. There is no split configuration that is optimal for both first load and repeat-visit caching.
Splitting reduces initial bytes and adds round trips, registry bookkeeping, waterfall risk, and cache-invalidation coupling — a change in a shared module invalidates every chunk containing it. So this is a product decision informed by your actual traffic mix, not a best practice. The failure lab measures it, and the result frequently surprises people who split aggressively on principle.
What you should be able to do after this module
| Capability | Evidence you have it |
|---|---|
| Diagnose a slow build | Enumerate causes in likelihood order, with the measurement for each |
| Design a cache key | Enumerate inputs exhaustively; prefer expensive-but-complete over cheap-but-partial |
| Judge a split strategy | Measure TTI on a throttled connection rather than counting bytes |
Evaluate sideEffects | Explain why it is necessary, and why a wrong one fails silently |
1. Why a Principal Engineer needs this
Build tooling is where most large frontend organisations lose the most time, and where decisions are most often made by folklore. Having built a bundler — even a small one — converts "webpack is slow" into specific claims about graph construction, transform caching, and invalidation.
The deeper connection: a bundler is an incremental compiler with a cache-invalidation problem,
which is the same problem as bi-07 style invalidation and bi-08 layout caching. Third time you
have met it in this track. That repetition is the point.
2. Build order
entry -> parse imports -> dependency graph -> transform -> bundle
- Parse a module's imports (use an existing JS parser).
- Build the dependency graph; detect cycles.
- Transform each module (reuse
fw-06). - Emit a bundle with a tiny runtime module registry.
- Code splitting on dynamic
import()— emit separate chunks, load on demand. - Tree shaking: mark exports used; drop unused. Then find a case where it is unsafe (side effects at module scope) and implement the conservative fallback.
- Source maps.
- Caching + incremental rebuild: content-hash each module; rebuild only affected paths.
- HMR concepts: what must be true for a module to be replaceable without a reload?
3. Failure Lab
- Stale cache. Key the cache on filename only, not content. Produce a wrong bundle.
- Unsafe tree shaking. Drop a module with an import side effect. Break the app.
- Cycle. Create a circular import with top-level usage; observe the partially-initialised module.
- Broken source map. Off-by-one the mapping and try to debug through it.
- Over-splitting. Split into 200 chunks; measure the network cost against one bundle on a throttled connection.
3.5 Deep dive: why tree shaking is unsound without help
Static removal of unused exports requires proving that removal changes nothing observable. That proof is impossible in general:
// module.js
import './polyfill'; // side effect at module scope
window.registry.push(thing); // side effect
export const unused = 1;
Removing module.js because unused is unreferenced deletes the polyfill and the registration.
The bundler cannot know those matter — and it cannot know they don't.
Hence "sideEffects": false in package.json: the author promising that importing this module
for its exports alone is safe.
Fourth instance of the pattern in this track:
key(fw-02),{passive:true}(bi-10),contain(bi-07),sideEffects(fw-07). Each time, a static analysis is provably insufficient, so the platform adds a way for the author to assert what the tool cannot derive.When you build a tool that must be conservative, the design question is not "how do I analyse harder?" — it is "what is the smallest promise the author could make that would unblock me?"
The corollary that bites in practice: "sideEffects": false is a claim, and a wrong claim
produces a bundle that is missing code, with no error. Which is the fw-06 risk again — author
assertions and compiler optimisations fail the same way, silently.
3.6 Deep dive: the cache-invalidation problem, for the third time
A bundler is an incremental compiler, and its central difficulty is knowing what to rebuild.
| Cache key | Fails when |
|---|---|
| filename | contents change |
| mtime | files touched without change; checkouts; containers |
| content hash | a dependency's content changed |
| content hash + resolved dependency hashes | correct — and requires a full graph |
| + config, plugin versions, env vars | correct in practice |
The last row is why build caches are invalidated by "unrelated" changes: a plugin upgrade or an env var legitimately changes the output of every module.
You have now met result-caching-keyed-on-complete-inputs four times: layout results (bi-08),
paint subsequences (bi-09), computed (fw-03), and here. The failure mode is identical every
time — an incomplete key produces stale output with no error — and so is the fix: enumerate the
inputs exhaustively, and prefer a key that is expensive to compute over one that is incomplete.
3.7 Deep dive: code splitting, and the cost nobody counts
Splitting reduces initial bytes and adds:
- an extra network round trip per chunk on the critical path (unless preloaded),
- runtime module-registry bookkeeping,
- risk of request waterfalls — chunk A loads, then discovers it needs chunk B,
- cache-invalidation coupling: a change in a shared module invalidates every chunk containing it.
That last point drives real bundler design. Naive splitting duplicates shared modules into every chunk; smarter splitting extracts common chunks, which then become a single invalidation point for everything. There is no configuration that is simultaneously optimal for first load and for repeat-visit caching, which is why this is a product decision informed by your actual traffic mix, not a best practice.
The measurement that settles it: on a throttled connection, compare time-to-interactive for one
bundle versus your split configuration. fw-07's failure lab does exactly this, and the result
frequently surprises people who split aggressively on principle.
3.8 Deep dive: HMR, and what it requires of a module
Hot module replacement needs a module to be replaceable at runtime, which requires:
- Identifying what changed and its dependents.
- Deciding a boundary — how far up the dependency graph to propagate before giving up and reloading.
- Preserving state across the swap — which the module must cooperate with.
- Disposing the old module's side effects (listeners, timers, subscriptions).
Point 4 is the one that makes HMR hard and unreliable in practice: a module that registered a
global listener and does not clean it up accumulates listeners on every hot update. That is why
HMR "works" for pure view components and is unreliable for stateful services — and why frameworks
provide explicit hot.dispose hooks.
The connection worth noticing: this is fw-02's effect-cleanup problem, at module granularity.
Any system that re-runs code must define what "undo the previous run" means, and the systems
that skip that definition are the ones that leak.
4. Trade-offs
Bundling vs native ES modules. Bundling reduces requests and enables cross-module optimisation; native modules remove a build step and improve cacheability. HTTP/2 and HTTP/3 changed this calculus — but not as much as commonly claimed. Measure.
Aggressive tree shaking vs correctness. Side effects make static removal unsound in general;
sideEffects metadata is the industry's admission that the analysis needs author help. Same
shape as keys in fw-02 and {passive:true} in bi-10 — an author-supplied guarantee
unlocking an optimisation.
Caching granularity. Fine-grained caches invalidate less and cost more bookkeeping.
Core terminology
| Term | Definition |
|---|---|
| Module graph | Directed graph of modules and their imports; the bundler's central data structure |
| Entry point | A graph root; each produces at least one chunk |
| Chunk | An emitted bundle file; the unit of network fetch and cache invalidation |
| Code splitting | Emitting separate chunks, typically at dynamic import() boundaries |
| Tree shaking | Removing unreferenced exports; unsound in general without author assertions |
sideEffects | package.json field: the author promising import-for-exports-only is safe |
| Module scope side effect | Work performed at import time, invisible to reference analysis |
| Content hash | Cache key derived from file contents — necessary, and by itself insufficient |
| Transitive key | Content hash plus resolved dependency hashes, config, plugin versions, env |
| Incremental rebuild | Rebuilding only the paths a change can reach |
| Request waterfall | Chunk A loads, then discovers it needs chunk B — serialised round trips |
| Common chunk | Shared code extracted once; a single invalidation point for everything importing it |
| Module registry | The tiny runtime that resolves and caches module instances in the output |
| HMR | Replacing a module at runtime without a reload |
hot.dispose | The author-supplied teardown that makes HMR survivable |
| Circular import | Cycle in the graph; produces partially-initialised modules at top-level use |
Mental models
An incomplete cache key produces stale output with no error. You have now met result-caching-
keyed-on-complete-inputs four times — layout results (bi-08), paint subsequences (bi-09),
computed (fw-03), and here. The failure mode is identical every time, and so is the fix:
enumerate the inputs exhaustively, and prefer a key that is expensive to compute over one that is
incomplete.
| Cache key | Fails when |
|---|---|
| filename | contents change |
| mtime | files touched without change; checkouts; containers |
| content hash | a dependency's content changed |
| content hash + resolved dependency hashes | correct — and requires a full graph |
| + config, plugin versions, env vars | correct in practice |
The last row is why build caches are invalidated by "unrelated" changes. A plugin upgrade or an env var legitimately changes the output of every module, and a cache that ignores that is silently wrong rather than merely cold.
When a tool must be conservative, the design question is not "how do I analyse harder?" — it is
"what is the smallest promise the author could make that would unblock me?" sideEffects is that
promise. So is key, {passive:true}, contain, and aria-setsize. Collecting these instances is
worth more than any of them individually, because you will design one.
An author assertion and a compiler optimisation fail the same way: silently. A wrong
"sideEffects": false produces a bundle missing code, with no error — exactly fw-06's risk. Both
need differential verification rather than trust.
Any system that re-runs code must define what "undo the previous run" means. HMR's hardest
requirement is disposal: a module that registered a global listener and does not clean it up
accumulates listeners on every hot update. That is why HMR "works" for pure view components and is
unreliable for stateful services — and it is fw-02's effect-cleanup problem at module granularity.
Common misconceptions
-
"Tree shaking removes unused code." It removes provably unreferenced exports. Module-scope side effects make removal unsound in general, which is why
sideEffectsexists at all. -
"
sideEffects: falseis a build optimisation flag." It is a correctness assertion. A wrong one ships a bundle missing a polyfill or a registration, with no error. -
"More code splitting is better." Splitting trades bytes for round trips, waterfall risk and invalidation coupling. Measure time-to-interactive on a throttled connection against a single bundle; the result often reverses the intuition.
-
"Content hashing makes the cache correct." Only for that file. A dependency's change is invisible unless the key includes resolved dependency hashes, config, plugin versions and environment.
-
"Our build cache is broken because unrelated changes invalidate it." Often it is working correctly — a plugin upgrade genuinely changes every module's output.
-
"HTTP/2 made bundling obsolete." It changed the calculus and less than commonly claimed. Bundling still enables cross-module optimisation and avoids per-request overhead. Measure.
-
"HMR is a solved feature." It is reliable for pure view components and unreliable for anything holding subscriptions, timers or listeners, because disposal is author-supplied and usually absent.
-
"Circular imports are a lint concern." With top-level usage they produce partially initialised modules at runtime — a real bug, not a style issue.
Interview talking points
- "A bundler is an incremental compiler with a cache-invalidation problem, and that's the third time that problem shows up in this material — Blink's style invalidation, layout result caching, and here. The failure mode is always the same: an incomplete key produces stale output with no error."
- "The rule I take from that is to prefer a cache key that's expensive to compute over one that's incomplete. Content hash isn't enough — you need resolved dependency hashes, config, plugin versions and env. Which is also why 'unrelated changes invalidate our build cache' is usually the cache working correctly."
- "Tree shaking is unsound in general. You can't prove that removing a module with import-time side
effects changes nothing observable.
sideEffects: falseis the author making the promise the analysis can't derive — and a wrong one ships a bundle missing code, silently. Same failure shape as a wrong patch flag." - "There's no split configuration that's simultaneously optimal for first load and repeat-visit caching, because extracting a common chunk creates a single invalidation point for everything importing it. So it's a product decision informed by your traffic mix, and I'd want time-to-interactive on a throttled connection rather than a byte count."
- "HMR's hard part isn't detecting the change, it's disposal. A module that registered a global listener accumulates one per hot update. That's the effect-cleanup problem at module granularity — any system that re-runs code has to define what undoing the previous run means."
5. Principal Engineer Review
-
Tree shaking is unsound without author annotations. Explain why, and evaluate
sideEffectsas a solution. -
Your build takes 8 minutes. Enumerate causes in order of likelihood and the measurement for each.
-
Code splitting reduces initial bytes and adds requests and complexity. What decides the split granularity, and what would you measure?
-
Compare a bundler's invalidation problem with Blink's style invalidation (
bi-07). What is genuinely the same, and what is different? -
A team wants to move from a bundler to native ESM in development. Argue both sides from mechanism.
Connections to other modules
fw-06— prerequisite. The bundler reuses your transform stage, and inherits its silent-failure risk profile.fw-02—keyis the same author-assertion pattern assideEffects;hot.disposeis effect cleanup at module granularity.fw-03—computed's cache invalidation is this problem in miniature, and gets it wrong the same way.bi-07— Blink's style invalidation: the same problem in C++, at a scale that forces better answers. Worth comparing directly.bi-08/bi-09— layout result and paint subsequence caching; both keyed on complete inputs for the same reason.fe-01— request waterfalls and long-task cost are what over-splitting buys you; the measurement lives there.