Skip to main content

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.

Problemrequire() 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:

  1. Resolution — turn each import './x' or import 'react' into an absolute file path. Applies node_modules lookup, package.json exports/main/module fields, path aliases, and extension guessing. This is where Cannot find module errors are born.
  2. 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).
  3. Transformation — run loaders/plugins: TypeScript → JS, JSX → createElement/jsx() calls, modern syntax → target syntax, CSS/SVG/image imports into something JS can hold.
  4. Dependency graph construction — link every module's imports to the resolved modules, forming the full graph.
  5. 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.
  6. Chunking / code-splitting — decide how many output files, and which module goes in which, for optimal caching and lazy-loading.
  7. 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 assetsimport 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:

CapabilityWhat it buys you
Code splittingBreak output into chunks loaded on demand (per-route, per-component).
Lazy loadingimport() dynamic imports → the bundler auto-creates a separate chunk fetched only when needed.
Tree-shakingImport one function from a 300-fn library, ship only that function.
HMR / dev serverInstant feedback loop with preserved state.
Asset pipelineImport CSS, images, fonts, SVG, JSON, ?raw, ?url, workers.
CSS handlingCSS Modules, PostCSS, Sass, extraction, scoping, minification.
Env & defineCompile-time constant injection + dead-code elimination (if (import.meta.env.DEV) disappears in prod).
Path aliases@/components instead of ../../../components.
Target / polyfillsDown-level modern syntax and inject only the polyfills your browserslist needs.
Source mapsDebug and read stack traces against your original TypeScript.
Bundle analysisVisualize what's fat and why (e.g. rollup-plugin-visualizer).
Multiple outputsESM + 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:

ToolLanguageRole / sweet spotStatus note
ViteJS + Rust coreDefault for new apps. Great DX, huge plugin ecosystem.Vite 8 adopts Rolldown for both dev & prod.
RolldownRustThe 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.
RspackRustWebpack drop-in replacement — same config/loaders/plugins, far faster.Rspack 2.x (2026), React Compiler support.
TurbopackRustPath of least resistance inside Next.js dev.Blazing dev server; production build still stabilizing — treat "faster builds" as dev restarts, not CI.
esbuildGoThe workhorse transformer under many other tools; great for libraries/scripts.Extremely fast; intentionally minimal feature set.
RollupJSThe library-bundling standard; defined the plugin API everyone copied.Being superseded by Rolldown, its spiritual successor.
webpackJSThe incumbent — maximum flexibility, largest plugin universe, slowest.Still everywhere in legacy; migrate to Rspack for speed.
ParcelRust/JSZero-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: false is 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.ts re-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, sideEffects mistakes) 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 exports map, 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