Concepts — A Minimal Bundler

Phase 5 · Spec area §38. Prerequisites: fw-06.


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.


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.