Using A Bundler
A bundler is the tool that packs my frontend code so the browser can load it fast. I write lots of small files (.ts, .tsx, .css, .svg, images); the browser would rather grab just a few files. The bundler sits between the way I write code and the way the browser runs it — and makes the two fit together.
In one sentence — a bundler starts at one starting file, follows every import / require from there to gather all the files my app really needs (this "what-needs-what" map is the dependency graph), and packs them into a few small, cleaned-up files the browser can load fast. On the way it transforms (turns newer code into what the browser understands), tree-shakes (drops code nothing uses), splits (breaks the output into load-when-needed pieces), and minifies (shrinks the file size).
Why bundlers exist (the problem)
A direct response to how the web used to load JavaScript.
1. The <script> tag era
Before modules, JS shipped as an ordered list of scripts:
<script src="jquery.js"></script>
<script src="utils.js"></script> <!-- depends on jquery -->
<script src="app.js"></script> <!-- depends on both -->
Every file lived in global scope. The problems:
Manual ordering — wrong order → undefined is not a function.
Scripts run top-to-bottom in document order, so a file must sit after whatever it depends on. Get it wrong and the dependency isn't defined yet when the code runs.
Global pollution — every var leaks onto window; two libs defining $ collide.
There was no module scope, so every top-level name became a global on window. Two libraries using the same name silently overwrite each other.
No dependency info — nothing declares what needs what; tracked by hand.
A <script> tag says "load this file," not "this needs that one first." The order lived only in my head, never in the code.
One request per file — 50 scripts = 50 round trips.
Two costs at once: each <script> is a separate download (a round trip with fixed latency, no matter how small), and it runs synchronously in order, blocking the parser. So 50 files = 50 fetches + 50 blocking runs.
2. Modules fixed authoring, not delivery
Module formats were invented to get real encapsulation. Each is a different answer to "how do I split code into pieces?" — and each solved something while leaving a problem behind. In order:
IIFE (~2010) — Immediately Invoked Function Expression
var utils = (function () {
var secret = 42; // private — trapped in the closure
return { double: (n) => n * 2 }; // public surface
})();
Solved — private scope. The closure hides secret; only the returned object is exposed.
Problem — still one global per module (utils on window), no way to declare dependencies, ordering still manual. Encapsulation without a module system — just a closure trick.
CommonJS (require) — Node.js
const fs = require('fs');
module.exports = { double: (n) => n * 2 };
Solved — real require/module.exports, private file scope, a dependency graph Node can resolve.
Problem — require() is a runtime, synchronous call — blocks while reading the file, and isn't browser-native (no require in a browser). Worse, require(someVar) takes dynamic args → can't be statically analyzed → can't be tree-shaken.
AMD (define) — browser, asynchronous
define(['jquery'], function ($) {
return { init: () => $('body') };
});
Solved — async loading in the browser (RequireJS) — no blocking, dependencies fetched in parallel.
Problem — verbose, nested define() callbacks, dependency arrays maintained by hand. Fixed browser loading but ugly enough that it died the moment bundlers + ESM showed up.
UMD — Universal Module Definition
(function (root, factory) {
if (typeof define === 'function' && define.amd) define([], factory); // AMD
else if (typeof module === 'object') module.exports = factory(); // CommonJS
else root.MyLib = factory(); // global
})(this, function () {
return { /* ... */ };
});
Solved — one file that runs everywhere — AMD, CommonJS, or plain global — by detecting the environment at runtime. The classic library distribution format.
Problem — boilerplate wrapper on every module, all detection at runtime, and no static structure → still un-tree-shakeable. A compatibility hack, not a real module system.
ESM (import / export) — ES2015+, the standard
import { double } from './utils.js';
export const triple = (n) => n * 3;
Solved — modules baked into the language itself. Imports/exports are static — fixed at parse time, before any code runs — so a tool can prove what's imported and what's dead. This static structure is exactly what unlocks tree-shaking.
Problem left — browsers only got native ESM ~2018, and even native loading hits the request waterfall at scale (next section) — so ESM is what I author, but I still bundle for delivery.
The common thread: every pre-ESM format was a runtime workaround bolted onto a language with no module system. ESM is the first one built into the language and statically analyzable — the only one that enables tree-shaking.
The catch across all of them: browsers historically understood none except plain globals. So a build step was needed to translate modularized source into something shippable. That build step is the bundler.
"But browsers support ESM natively now — why still bundle?"
True since ~2018 (<script type="module">). But native ESM in production has a fatal flaw at scale: the request waterfall. The browser fetches the entry, parses it to discover its imports, fetches those, parses them to discover their imports, and so on — a serial, depth-by-depth cascade. An app with 2,000 modules becomes 2,000 sequential-ish requests. Bundling collapses that graph into a handful of files, and each file's contents load in parallel. Native ESM solved authoring; bundlers still win delivery.
The mental model: entry → graph → output
A bundler is fundamentally a graph processor.
entry.ts
├── imports Button.tsx
│ └── imports styles.css
│ └── imports icon.svg
├── imports api.ts
│ └── imports config.ts
└── imports lodash-es (only `debounce`)
▼ bundler walks & analyzes the graph ▼
main-a1b2c3.js (your code + used deps, minified)
vendor-d4e5f6.js (rarely-changing third-party code)
main-a1b2c3.css (extracted, deduped styles)
You give it entry points; it discovers everything reachable and decides how to carve that graph into chunks.
What a bundler actually does — the pipeline
This is the part most tutorials skip. A production bundler runs roughly these stages:
- Resolution — turn each
import './x'orimport 'react'into an absolute file path. Appliesnode_moduleslookup,package.jsonexports/main/modulefields, path aliases, and extension guessing. This is whereCannot find moduleerrors are born. - Loading & parsing — read each file and parse it into an AST (Abstract Syntax Tree). This is the single most expensive step, and the main reason the new generation is written in Rust/Go (parsing in native code is far cheaper than in JS).
- Transformation — run loaders/plugins: TypeScript → JS, JSX →
createElement/jsx()calls, modern syntax → target syntax, CSS/SVG/image imports into something JS can hold. - Dependency graph construction — link every module's imports to the resolved modules, forming the full graph.
- Optimization — the value-add layer:
- Tree-shaking — drop exports nobody imports (dead-code elimination across modules).
- Scope hoisting / module concatenation — merge modules into one scope to kill per-module wrapper overhead.
- Minification — rename locals, strip whitespace/comments, fold constants.
- Chunking / code-splitting — decide how many output files, and which module goes in which, for optimal caching and lazy-loading.
- Output (emit) — write the final JS/CSS/asset files, with content-hashed names for cache-busting, plus source maps so debuggers map minified code back to your original
.ts.
A bundler is not (only) a transpiler. A transpiler (Babel, tsc, swc, Oxc) transforms one file's syntax → another. A bundler orchestrates transpilers across a whole graph and then does linking, tree-shaking, splitting, and emission. Modern bundlers embed a transpiler (esbuild embeds its own; Vite/Rolldown use Oxc), which is why the two ideas blur — but they're distinct jobs.
What you get — the benefits
- One (or few) requests instead of thousands — collapses the module waterfall.
- Smaller payloads — tree-shaking + minification routinely cut bundle size by more than half.
- Use any module format — author in ESM/TS, ship whatever the target browser needs.
- First-class non-JS assets —
import logo from './logo.svg'just works. - A fast dev loop — dev server + Hot Module Replacement (HMR) swaps a changed module in-place without a full reload, preserving app state.
- Environment-aware builds — inject
process.env/import.meta.env, strip dev-only code, produce different output per target. - Cache-friendly output — content hashing means a one-line change re-downloads one small chunk, not your whole app.
What you can do with a bundler — the feature surface
Think of these as the knobs you'll actually reach for:
| Capability | What it buys you |
|---|---|
| Code splitting | Break output into chunks loaded on demand (per-route, per-component). |
| Lazy loading | import() dynamic imports → the bundler auto-creates a separate chunk fetched only when needed. |
| Tree-shaking | Import one function from a 300-fn library, ship only that function. |
| HMR / dev server | Instant feedback loop with preserved state. |
| Asset pipeline | Import CSS, images, fonts, SVG, JSON, ?raw, ?url, workers. |
| CSS handling | CSS Modules, PostCSS, Sass, extraction, scoping, minification. |
Env & define | Compile-time constant injection + dead-code elimination (if (import.meta.env.DEV) disappears in prod). |
| Path aliases | @/components instead of ../../../components. |
| Target / polyfills | Down-level modern syntax and inject only the polyfills your browserslist needs. |
| Source maps | Debug and read stack traces against your original TypeScript. |
| Bundle analysis | Visualize what's fat and why (e.g. rollup-plugin-visualizer). |
| Multiple outputs | ESM + CJS, or app + library builds, from one config (library mode). |
The under-the-hood insights (pro tier)
Tree-shaking only works because ESM is static
You cannot conditionally import at the top level — the import list is fixed at parse time. That static guarantee lets the bundler prove an export is unused and delete it. CommonJS's require() is a runtime function call with dynamic arguments, so it's fundamentally un-shakeable. This is the reason to publish and consume ESM libraries.
The catch: tree-shaking is conservative around side effects. If module A is imported only for a console.log or a polyfill it runs on load, the bundler must keep it — unless package.json declares "sideEffects": false, which is a promise that "importing my modules does nothing but define exports." Getting that flag right is often the difference between a 40 KB and a 400 KB bundle.
Why dev and prod use different strategies (Vite's key idea)
Vite popularized a two-mode architecture that's now the norm:
- Dev: serve source over native ESM, transforming files on demand as the browser requests them. Startup is near-instant because nothing is bundled up front — you only pay for the modules actually hit.
- Prod: bundle everything (historically Rollup, now Rolldown) to avoid the request waterfall and to enable full tree-shaking + chunking.
The tension this creates — a fast unbundled dev pipeline that behaves subtly differently from the bundled prod pipeline — is exactly what Rolldown is built to erase (see below).
Why the new bundlers are so much faster
The bottleneck was never the algorithm; it was running JS to build JS. Parsing thousands of files into ASTs, then re-serializing, is death-by-a-thousand-allocations in a single-threaded JS runtime. The fix:
- Native languages — esbuild (Go), Rolldown/Rspack/Turbopack/swc/Oxc (Rust) parse and transform in compiled code.
- Real parallelism — native threads across all CPU cores, not blocked by JS's single main thread.
- Shared AST / fewer passes — do transform + minify + bundle over a shared representation instead of re-parsing between tools.
Order-of-magnitude speedups (10–100×) come from that shift, not from clever caching alone.
Scope hoisting & content hashing (the two quiet wins)
- Scope hoisting merges modules into a single shared scope where safe, eliminating the tiny function-wrapper each module otherwise needs. Fewer closures = smaller, faster code.
- Content hashing (
main-a1b2c3.js) names each chunk by a hash of its contents. Change one file → only that chunk's hash changes → users re-download only what changed while everything else stays cached. This is why you split rarely-changing vendor code from your fast-moving app code.
The 2026 bundler landscape
The field has stratified into roles, not a single winner. As of 2026:
| Tool | Language | Role / sweet spot | Status note |
|---|---|---|---|
| Vite | JS + Rust core | Default for new apps. Great DX, huge plugin ecosystem. | Vite 8 adopts Rolldown for both dev & prod. |
| Rolldown | Rust | The Rust bundler Vite is built on — unifies dev/prod, Rollup-compatible plugins. | Hit 1.0 stable (May 2026); ~4× faster prod builds vs old JS Rollup. |
| Rspack | Rust | Webpack drop-in replacement — same config/loaders/plugins, far faster. | Rspack 2.x (2026), React Compiler support. |
| Turbopack | Rust | Path of least resistance inside Next.js dev. | Blazing dev server; production build still stabilizing — treat "faster builds" as dev restarts, not CI. |
| esbuild | Go | The workhorse transformer under many other tools; great for libraries/scripts. | Extremely fast; intentionally minimal feature set. |
| Rollup | JS | The library-bundling standard; defined the plugin API everyone copied. | Being superseded by Rolldown, its spiritual successor. |
| webpack | JS | The incumbent — maximum flexibility, largest plugin universe, slowest. | Still everywhere in legacy; migrate to Rspack for speed. |
| Parcel | Rust/JS | Zero-config option. | Niche but still pleasant for quick projects. |
How to choose
- New frontend app (React/Vue/Svelte/vanilla)? → Vite. It's the safe default and the ecosystem center of gravity.
- On a big legacy webpack app? → Rspack. Nearly drop-in, keep your loaders/plugins, get 5–10× faster builds.
- Building on Next.js? → Turbopack for dev (it's wired in); Next handles prod.
- Publishing a library? → tsup/esbuild or Rolldown in library mode — you want fast ESM+CJS output, not a dev server.
- Bundling a tiny script or CLI? → esbuild directly. One command, done.
The 2026 one-liner: Vite is the default, Rspack is the webpack migration path, Turbopack is the Next.js dev story, and Rolldown is the Rust engine the whole Vite world is consolidating onto.
A minimal, concrete example (Vite)
npm create vite@latest my-app -- --template react-ts
cd my-app && npm install && npm run dev
A tiny config showing the knobs discussed above:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': '/src' }, // path alias → no ../../../
},
build: {
sourcemap: true, // debug prod against TS source
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'], // split stable deps → better caching
},
},
},
},
});
And lazy-loading a route — the bundler auto-splits this into its own chunk:
// Dashboard is fetched only when the user navigates to it
const Dashboard = lazy(() => import('./routes/Dashboard'));
Gotchas worth internalizing
sideEffects: falseis a contract, not a suggestion. Set it wrong (you do have a side-effectful import) and the bundler silently drops needed code. Set it missing when you could set it and your bundle bloats.- Barrel files (
index.tsre-exporting everything) can defeat tree-shaking and balloon dev-server module counts. Import from the concrete path when it matters. - Dynamic
import()with a fully variable path can't be statically split — the bundler either bundles everything it might match or fails. Keep at least a static prefix. - Dev ≠ prod. Native-ESM dev can hide bundling bugs (circular deps,
sideEffectsmistakes) that only surface in the prod build. Always test the actual production build. - CJS/ESM interop is where nights die. A dependency shipping only CJS, or a wrong
exportsmap, produces "named export not found" errors that are about format, not your code.
Summary
- A bundler turns a graph of modules into a small set of optimized files — solving JavaScript's delivery problem, which native ESM alone doesn't.
- Its pipeline is resolve → parse → transform → graph → optimize → split → emit, and its superpowers (tree-shaking, splitting, HMR, content hashing) all fall out of that.
- Tree-shaking exists because ESM is statically analyzable; the new speed exists because the work moved from JS to Rust/Go with real parallelism.
- In 2026: Vite by default, Rspack to escape webpack, Turbopack inside Next.js, all converging on Rolldown underneath.
Further resources
- Vite — Why Vite — the canonical explanation of the dev/prod split.
- Rolldown docs — the Rust bundler Vite is consolidating on.
- Rspack — webpack-compatible, Rust-powered.
- esbuild — architecture — read why it's fast; it's a masterclass.
- webpack concepts — still the best long-form explanation of the core ideas (entry, output, loaders, plugins).