跪拜 Guibai
← All articles
Frontend

Build Tool Truths: Benchmarks from a Handwritten Mini Bundler

By 前端阿凡 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Build tooling decisions directly affect iteration and deployment speed; this experiment gives hard numbers on the trade-offs between chunk granularity, hash chaining, and tree-shaking correctness, which any team tuning its bundler can use to decide where to invest.

Summary

The article walks through building a complete mini bundler from scratch, with 33 assertions and 5 differential tests against native ESM. Its results challenge common beliefs: regex-based scanning is 43.7x faster but misidentifies imports in comments/strings 3/3 times; tree-shaking mostly reduces output by excluding unreachable modules, not clever elimination; per-module chunking cuts invalidation to 0.4% but multiplies requests; chaining dependency hashes boosts blast radius 214x. It also measures performance hot spots and shows that optimizing code with charCode comparisons and single-pass linking yields multi-fold gains, and that hand-written BigInt hashing is far slower than native sha256. The article ends with practical advice on when to write your own bundler and how to diagnose slow builds by stage.

Takeaways
— A regex-based import scanner is 43.7x faster than a real tokenizer but false-positives 3 out of 3 disguised import statements inside comments, strings, and regex literals, which can silently create missing or extra dependencies at runtime.
— In a 300-module project with only 10% of modules referenced, tree-shaking reduced output by 96.5%, but 270 of 301 modules never entered the module graph; the reduction comes from module reachability, not dead-code elimination cleverness.
— Changing one line of live code invalidates 100% of bytes with a single chunk, 61.1% with shared chunks, and 0.4% (89 bytes) with per-module chunking—but per-module requires 141 HTTP requests, trading size for request count.
— Chaining dependency hashes amplifies cache blast radius 214.4x: a one-leaf change invalidates 0.4% (89B) with own-content hashing versus 91.8% (18.6KB) across 124 chunks with chained hashing.
— Determinism is enforced by a fixed topo-order; shuffling file enumeration order produces 5 different hashes, which would silently destroy CDN cache hit ratios.
— The tokenizer's hot path is character classification: switching from regex .test() to charCode comparisons makes it 4.9x faster (39.3ms vs 8.0ms for 2M checks).
— Rewriting the linker from O(statements × tokens) to a single token pass with pointer advance yields a 3.13x speedup (65.9ms to 21.1ms) while producing byte-identical output.
— Hand-written BigInt FNV-1a hashing is 29.4x slower than native sha256 (16.26 MB/s vs 478.39 MB/s); a Math.imul dual-channel version is 344.38 MB/s, still slower than native.
— Adding one top-level side-effect statement to every module in a 100-module project expands the bundle from 4.0KB to 10.2KB (2.55x), illustrating both the value and risk of the package.json sideEffects flag.
— The 5 differential tests against native ESM caught 4 bugs that all left builds green but produced wrong output, including missed recursion in template interpolations and missing re-export edges.
— In a 3000-module project, only 1201 modules enter the graph because the entry references only 40%; graph building is approximately linear at 0.017ms per module.
— The full pipeline for 3000 modules breaks down as: graph building (incl. parsing) 58.5%, tree-shaking 14.8%, linking 18.2%, hashing 8.4%, with pure lexing at 31.5%.
Conclusions

The most striking claim is that tree-shaking's biggest gains come from excluding modules from the graph rather than from clever liveness analysis; this implies optimizing entry-point fan-in is more effective than tuning a shake algorithm.

The 214x blast-radius amplification demonstrates that chunk granularity and hash chaining are two independent knobs for cache behavior; teams that conflate them risk either constant stale caches or deploy-time 404s.

The 'green build but wrong output' bugs highlight a deeper truth: correctness in a bundler cannot be proven by unit tests alone; differential testing against native ESM is the only reliable safety net, and it only works if your fixtures actually exercise the semantics that matter.

The performance measurement section warns that aligning baselines to reachable modules is as important as measuring; without it, you can get absurd conclusions like 'graph building is faster than lexing', which would lead to misdirected optimization decisions.

Concepts & terms
Scope hoisting
A bundling technique that renames each module's top-level bindings to globally unique names and merges multiple modules into a single scope, reducing runtime overhead from module wrappers. It requires careful handling of property names, object keys, and template interpolations.
sideEffects field
A package.json flag that tells bundlers a module is side-effect-free, enabling tree-shaking to drop the entire module if no exports are used. Setting it incorrectly on a module with actual side effects can cause those effects to be removed, leading to production failures.
Differential testing
A verification method that runs the same fixture through two implementations (here, native ESM import and the bundled output) and compares results. It catches semantic differences that unit tests miss, such as incorrect renaming or missing re-export edges.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗