Skip to content
yutils

How Bundlers Actually Work (Webpack, Vite, esbuild, Turbopack)

What bundlers do beyond "glue files together" — module resolution, dependency graphs, tree shaking, code splitting, source maps, HMR, and why Vite is 100× faster than Webpack in dev.

~9 min read

Run npm run build and the output says "1234 modules transformed, 8 chunks, gzip 234 KB." What just happened? A bundler does more than "glue files together" — module resolution, tree shaking, code splitting, source maps, HMR. This guide walks through what bundlers actually do, the differences between Webpack / Vite / esbuild, and why dev and production builds behave so differently.

Why bundlers exist

Modern JavaScript uses ESM (import / export) but shipping it raw to browsers is slow:

Source:
src/
├── App.tsx              import Button from './Button'
├── Button.tsx           import {clsx} from 'clsx'
├── Header.tsx           import logo from './logo.png'
└── ...                  ~500 modules

Browser:
- 500 HTTP requests → even HTTP/2 multiplex pays round-trip cost
- "import" path resolution differs from build tools
- TypeScript / JSX / Sass need transpilation
- Every dependency must be loaded before execution

Bundlers analyze all of that and produce optimized output.

Five bundler steps

1. Module resolution    — find the actual file behind each "import"
2. Dependency graph    — walk every module's imports
3. Transformation       — TypeScript → JS, JSX → JS, Sass → CSS
4. Optimization         — tree shaking, minify, code splitting
5. Output               — write chunks, hash assets, emit source maps

Step 1 — Module resolution

import App from './App';
import {clsx} from 'clsx';
import logo from '@/assets/logo.png';

The bundler resolves:
1. './App'        → ./App.tsx (extension lookup) or ./App/index.tsx
2. 'clsx'         → node_modules/clsx/package.json's "main" or "exports"
3. '@/assets/...' → tsconfig.json paths alias → src/assets/...

npm package resolution priority (modern):

  1. package.json exports field (conditional exports)
  2. module field (ESM, tree-shake friendly)
  3. main field (CommonJS fallback)
  4. index.js if none of the above

Step 2 — Dependency graph

Entry: src/App.tsx
  ├─ Button.tsx
  │   └─ clsx (npm)
  ├─ Header.tsx
  │   ├─ logo.png         ← asset
  │   └─ Nav.tsx
  └─ utils.ts
      └─ lodash-es (npm)

The bundler builds a directed graph → tracks every dependency,
deduplicates shared modules, identifies unreachable code
(candidates for tree shaking).

Circular dependencies don't loop infinitely, but they obscure ordering. Avoid them when possible.

Step 3 — Transformation

  • TypeScript → JavaScript via Babel or swc or esbuild
  • JSX → React.createElement / jsx-runtime
  • Sass / Less → CSS
  • Images / SVGs → JS modules — either the file's URL or inline base64
  • Polyfills injected based on the browserslist target

Try it — paste a production bundle into JavaScript Formatter to re-format the minified code and see how familiar source got transformed.

Step 4 — Optimization

Tree shaking — drop unused exports

// utils.ts
export function used() { /* ... */ }
export function unused() { /* ... */ }

// App.tsx
import {used} from './utils';
used();

// Bundle output:
function used() { /* ... */ }
// unused is removed

Requires ESM (import / export). CommonJS (require) can't be tree-shaken (resolved at runtime).

Watch out for side effects — some modules do things just by being imported (polyfills, CSS injection):

{
  "name": "my-lib",
  "sideEffects": false  ← safe to tree-shake
}

{
  "name": "my-lib",
  "sideEffects": ["./src/polyfill.js"]  ← preserve only this
}

Code splitting — separate chunks

// Static import → same chunk
import {Heavy} from './Heavy';

// Dynamic import → separate chunk
const Heavy = lazy(() => import('./Heavy'));

// Bundler output:
main.abc.js         ← initial load
Heavy.def.chunk.js  ← lazy-loaded

Route-based splitting — each page is its own chunk. First-page load stays small. Vendor splitting — separate chunk for npm dependencies (lodash, react), so they stay cached across deploys.

Minification

  • Variable renaming (userInputa)
  • Whitespace and comments dropped
  • Dead code elimination
  • Constant folding (1+12)

Terser (JS), cssnano (CSS), html-minifier (HTML).

Step 5 — Output + asset hashing

dist/
├── index.html                    ← entry
├── assets/
│   ├── main-a1b2c3.js           ← content hash
│   ├── main-a1b2c3.js.map       ← source map
│   ├── chunk-Heavy-d4e5f6.js
│   ├── logo-7890ab.png
│   └── styles-cdef12.css

About the hash:
- Hash of the file's contents
- Contents unchanged → hash unchanged → CDN cache hit
- Only index.html is fetched fresh each time

Source maps map byte positions in the bundle back to the original source — making production debugging readable.

Bundler comparison — Webpack / Vite / esbuild

Webpack (2014)

  • The original standard. Most configurable
  • JS-implemented → slow on large projects (30s+ builds)
  • Huge plugin / loader ecosystem
  • Dev server bundles everything → fast HMR after cold start, but cold start is slow

esbuild (2020, Go)

  • 10-100× faster than Webpack — written in Go, parallel
  • Trade-off — fewer plugins, partial CSS support
  • Best for libraries and simple scripts
  • Used internally by Vite for dev-time pre-bundling

Vite (2020, Evan You)

  • Dev = native ESM. Browser resolves imports directly; minimal bundling.
  • node_modules pre-bundled with esbuild. Project code is fetched on demand.
  • Only the first page load matters; subsequent changes retransform a single module. Very fast HMR.
  • Production uses Rollup (mature, stable, strong tree-shaking).

Turbopack (2022, Vercel)

  • Webpack's successor. Default in Next.js
  • Rust + incremental compilation
  • Beta as of 2024, moving to production

Rolldown (2024)

  • Future Rust-based production bundler for Vite
  • Rollup-compatible with ~10× the speed

Why Vite is so fast in dev — native ESM

Webpack dev server:
- Bundles all code + node_modules (tens of thousands of modules)
- Cold start: 10-30 seconds
- File change → rebundle → HMR (fast after the first build)

Vite dev server:
- Pre-bundles only node_modules with esbuild (~seconds)
- Project code served via <script type="module"> — browser fetches on demand
- First page — fetches only what it needs (lazy)
- File change → reprocess only that module + HMR
- Cold start: under a second

Trade-off — initial page load makes many small fetches (HTTP/2 multiplex helps). Production still bundles with Rollup; native ESM is dev-only.

HMR — Hot Module Replacement

Swap the edited file in without a full page reload:

// Edit Button.tsx:
1. File watcher detects the change
2. Bundler recompiles only Button.tsx
3. WebSocket pushes the update to the browser
4. Browser receives the new Button module
5. React's HMR runtime swaps just the Button component
   - State preserved
   - Other components untouched

React Fast Refresh (Next.js / Vite) is React-aware HMR — even component state is preserved. Style changes apply instantly (no re-render).

Common pitfalls

1. Imports that defeat tree shaking

// Bad — drags in the entire lodash bundle (~70 KB)
import _ from 'lodash';
_.debounce(fn);

// Good — named import (tree-shaken, ~2 KB)
import {debounce} from 'lodash-es';
debounce(fn);

// Or deep import
import debounce from 'lodash/debounce';

2. Dynamic import with computed paths

// Bad — bundler can't know the target
const module = await import(`./pages/${page}.tsx`);
// → bundler creates a chunk for every file under ./pages

// Good — explicit entries
const pages = {
  home: () => import('./pages/home'),
  about: () => import('./pages/about'),
};
pages[page]();

3. CSS-in-JS bundle weight

styled-components / emotion add 30 KB+ runtime. CSS Modules and Tailwind ship lighter. Linaria / vanilla-extract have zero runtime.

4. Source maps in production

Leaving .map files in the public folder exposes original source. Upload to Sentry-style error tracking and strip from the public path.

5. Duplicated modules in monorepos

The same npm library bundled twice across packages. yarn / pnpm workspaces hoist + use bundler resolve.dedupe.

References

Summary

  • Five steps — module resolution / dependency graph / transformation / optimization / output.
  • Tree shaking removes unused exports — ESM only, watch for side effects.
  • Code splitting via dynamic imports, routes, and vendor chunks.
  • Asset hashing keeps CDN caches happy forever; only index.html is fetched fresh.
  • Webpack = standard, slow. esbuild = Go, 10-100× faster. Vite = native ESM dev + esbuild pre-bundle + Rollup production. Turbopack = Next.js' Rust successor.
  • Vite is fast in dev because it doesn't bundle project code — the browser fetches ESM on demand.
  • HMR — file watcher → partial rebuild → WebSocket → React Fast Refresh. State preserved.
  • Try it — re-format a production bundle with JavaScript Formatter to see what optimizations did.
Back to guides