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

CapabilityEvidence you have it
Diagnose a slow buildEnumerate causes in likelihood order, with the measurement for each
Design a cache keyEnumerate inputs exhaustively; prefer expensive-but-complete over cheap-but-partial
Judge a split strategyMeasure TTI on a throttled connection rather than counting bytes
Evaluate sideEffectsExplain 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
  1. Parse a module's imports (use an existing JS parser).
  2. Build the dependency graph; detect cycles.
  3. Transform each module (reuse fw-06).
  4. Emit a bundle with a tiny runtime module registry.
  5. Code splitting on dynamic import() — emit separate chunks, load on demand.
  6. 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.
  7. Source maps.
  8. Caching + incremental rebuild: content-hash each module; rebuild only affected paths.
  9. HMR concepts: what must be true for a module to be replaceable without a reload?

3. Failure Lab

  1. Stale cache. Key the cache on filename only, not content. Produce a wrong bundle.
  2. Unsafe tree shaking. Drop a module with an import side effect. Break the app.
  3. Cycle. Create a circular import with top-level usage; observe the partially-initialised module.
  4. Broken source map. Off-by-one the mapping and try to debug through it.
  5. 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 keyFails when
filenamecontents change
mtimefiles touched without change; checkouts; containers
content hasha dependency's content changed
content hash + resolved dependency hashescorrect — and requires a full graph
+ config, plugin versions, env varscorrect 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:

  1. Identifying what changed and its dependents.
  2. Deciding a boundary — how far up the dependency graph to propagate before giving up and reloading.
  3. Preserving state across the swap — which the module must cooperate with.
  4. 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

TermDefinition
Module graphDirected graph of modules and their imports; the bundler's central data structure
Entry pointA graph root; each produces at least one chunk
ChunkAn emitted bundle file; the unit of network fetch and cache invalidation
Code splittingEmitting separate chunks, typically at dynamic import() boundaries
Tree shakingRemoving unreferenced exports; unsound in general without author assertions
sideEffectspackage.json field: the author promising import-for-exports-only is safe
Module scope side effectWork performed at import time, invisible to reference analysis
Content hashCache key derived from file contents — necessary, and by itself insufficient
Transitive keyContent hash plus resolved dependency hashes, config, plugin versions, env
Incremental rebuildRebuilding only the paths a change can reach
Request waterfallChunk A loads, then discovers it needs chunk B — serialised round trips
Common chunkShared code extracted once; a single invalidation point for everything importing it
Module registryThe tiny runtime that resolves and caches module instances in the output
HMRReplacing a module at runtime without a reload
hot.disposeThe author-supplied teardown that makes HMR survivable
Circular importCycle 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 keyFails when
filenamecontents change
mtimefiles touched without change; checkouts; containers
content hasha dependency's content changed
content hash + resolved dependency hashescorrect — and requires a full graph
+ config, plugin versions, env varscorrect 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

  1. "Tree shaking removes unused code." It removes provably unreferenced exports. Module-scope side effects make removal unsound in general, which is why sideEffects exists at all.

  2. "sideEffects: false is a build optimisation flag." It is a correctness assertion. A wrong one ships a bundle missing a polyfill or a registration, with no error.

  3. "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.

  4. "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.

  5. "Our build cache is broken because unrelated changes invalidate it." Often it is working correctly — a plugin upgrade genuinely changes every module's output.

  6. "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.

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

  8. "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: false is 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

  1. Tree shaking is unsound without author annotations. Explain why, and evaluate sideEffects as a solution.

  2. Your build takes 8 minutes. Enumerate causes in order of likelihood and the measurement for each.

  3. Code splitting reduces initial bytes and adds requests and complexity. What decides the split granularity, and what would you measure?

  4. Compare a bundler's invalidation problem with Blink's style invalidation (bi-07). What is genuinely the same, and what is different?

  5. 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 — key is the same author-assertion pattern as sideEffects; hot.dispose is 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.