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