📑 Contents Why Bundlers Exist The Bundling Pipeline Module Systems Dependency Graph Transforms & Loaders Tree Shaking Code Splitting Chunk Strategy Minification Source Maps HMR (Dev Server) Asset Handling Bundler Comparison Dev vs Production Mental Model References

1. Why Bundlers Exist

In 2010, browsers didn't support modules natively. JavaScript had no official way to split code into files and import them. Developers worked around this with globals, IIFEs, or script tags — all fragile and unscalable.

The Three Problems Bundlers Solve

🔴 Problem 1 — No Modules in Browser

Browsers couldn't import or require(). You had to manually concatenate files or rely on global variables leaking into window.

🔴 Problem 2 — HTTP/1.1 Waterfall

Each <script> tag is a separate HTTP request. 100 modules = 100 round trips. HTTP/1.1 allows only 6 concurrent connections per domain. Sequential loading killed performance.

🔴 Problem 3 — No Transforms

Browsers only understand vanilla JS. JSX, TypeScript, Sass, modern syntax that needs transpiling — none of it works in a browser without a build step.

✅ What Bundlers Do

Take many source files → apply transforms → resolve dependencies → combine into few optimized files the browser can run efficiently.

💡

HTTP/2 note: HTTP/2 supports multiplexing (many requests over one connection), which reduces the penalty for many small files. But bundlers are still valuable for transforms, tree shaking, and code splitting — HTTP/2 didn't make bundlers obsolete.

2. The Bundling Pipeline

Every bundler follows the same fundamental pipeline, regardless of tool. The steps are always: find → read → parse → transform → link → optimize → emit.

📌
Entry Point
Start file
🔍
Resolve
Find the file
📖
Load
Read from disk
🌳
Parse
AST
⚙️
Transform
Transpile / TS
🔗
Link
Merge graph
✂️
Tree-shake
Remove dead
📋
Chunk
Split output
🗜️
Minify
Compress
📤
Emit
Write files

Step-by-Step Breakdown

  1. 1
    Entry Point — You tell the bundler where to start (src/main.ts, src/index.jsx). Everything else is discovered from here.
  2. 2
    Resolve — When the bundler sees import React from 'react', it must figure out what file that maps to. It checks node_modules/react, reads package.json exports field, follows the resolution algorithm. This produces an absolute file path.
  3. 3
    Load — Read the file from disk. This is also where plugins intercept to handle non-JS files (CSS, images, WASM). A CSS loader transforms import './styles.css' into JavaScript that injects a <style> tag at runtime.
  4. 4
    Parse → AST — The source string is parsed into an Abstract Syntax Tree (AST) — a tree data structure representing every token in the code. Import statements, function calls, variable declarations are all nodes in this tree. The bundler needs the AST to understand the code structurally.
  5. 5
    Transform — Plugins walk the AST and modify it. TypeScript types are stripped, JSX is converted to React.createElement() calls, modern syntax is downgraded to ES5. The AST is then serialized back to a JS string.
  6. 6
    Dependency Discovery + Link — After parsing each module, the bundler extracts its imports and adds them to a work queue. This process repeats recursively until every module in the graph has been processed. The result is a complete module graph.
  7. 7
    Tree Shaking — Walk the module graph and mark which exports are actually used. Anything unreachable from the entry point gets flagged as dead code and removed from the output.
  8. 8
    Chunking — Decide which modules go in which output file. Typically: one main chunk, one vendor chunk (node_modules), and async chunks for lazy-loaded routes.
  9. 9
    Minification — Rename variables to single letters, remove whitespace and comments, collapse constant expressions. Makes files 60–80% smaller.
  10. 10
    Emit — Write output files to disk: dist/main.js, dist/vendor.js, dist/chunk-123.js. Also generates index.html with correct <script> tags injected.

3. Module Systems

Bundlers must handle several different module formats — code you write, code from npm packages, and code targeting different environments.

Format Syntax Where Used Tree-shakeable?
ESM (ES Modules) import / export Modern browsers, modern npm packages YES — static, analyzable
CJS (CommonJS) require() / module.exports Node.js, legacy npm packages NO — dynamic, runtime only
AMD define([], function(){}) Legacy (RequireJS era) NO — obsolete
UMD CJS + AMD + global fallback Libraries targeting all environments PARTIAL — bundler-dependent
IIFE Self-executing function wrapper Old browser scripts, some CDN builds NO

Why ESM Is the Only Tree-Shakeable Format

The key difference: ESM imports and exports are static — they appear at the top level of a file and cannot change at runtime. The bundler can read the file and know exactly what is exported and imported before running any code.

ESM — Static (analyzable)
// bundler sees this at parse time
import { format } from 'date-fns'

export function greet(name) {
  return `Hello ${name}`
}

// bundler knows: only 'format' is used,
// and 'greet' is exported
CJS — Dynamic (cannot analyze)
// bundler cannot know what this imports
const utils = require(someVar)

// or conditionally:
if (process.env.NODE_ENV === 'test') {
  module.exports = require('./mock')
} else {
  module.exports = require('./real')
}
⚠️

The CJS problem: Many npm packages still ship only CJS. Bundlers like webpack and Rollup can interop with CJS (convert it to ESM-compatible format), but they cannot tree-shake it. The entire package gets included even if you only use one function from it.

4. The Dependency Graph

A bundler builds a directed graph where each node is a module and each edge is an import relationship. This graph is everything — it drives tree shaking, chunking, and output generation.

Module Graph Example: main.ts ├── import './router'router.ts │ ├── import './pages/Home' → pages/Home.tsx │ │ ├── import './Button' → Button.tsx (shared!) │ │ └── import 'lodash' → node_modules/lodash │ └── import './pages/About' → pages/About.tsx │ └── import './Button' → Button.tsx (shared! same node) └── import './api'api.ts └── import 'axios' → node_modules/axios Button.tsx appears ONCE in the graph even though two modules import it. The bundler processes it once and reuses the result.

How the Graph Is Built (Algorithm)

  1. 1
    Start with the entry file. Add it to a queue and a visited set.
  2. 2
    Dequeue a module. Read and parse it. Extract all import statements.
  3. 3
    For each import: resolve the path to an absolute file path. If not already in the visited set, add it to the queue.
  4. 4
    Repeat until the queue is empty. Every reachable module has been processed exactly once.
🛠

Parallelism: Modern bundlers (esbuild, Vite's dep pre-bundler) process the graph in parallel across multiple CPU threads. The queue is a concurrent work-stealing queue — multiple workers pick up tasks simultaneously, massively speeding up large projects.

Circular Dependencies

Module A imports B, and B imports A. Both ESM and CJS handle this differently, but bundlers must detect and handle cycles to avoid infinite loops during graph construction. ESM allows cycles (exports may be temporarily undefined at first), CJS may produce partially initialized objects.

Circular dependencies are a code smell. They don't always cause bugs but make reasoning about load order and initialization very difficult. Bundlers warn about them; you should fix them.

5. Transforms & Loaders

Bundlers speak JavaScript natively. Everything else — TypeScript, JSX, CSS, images, WASM — requires a transform (Rollup / Vite call them plugins; webpack calls them loaders).

The Transform Pipeline per Module

Source file │ ▼ Load hook → read raw bytes from disk (or network) ▼ Transform hook → apply plugin chain in order: │ 1. TypeScript plugin → strip types, compile to JS │ 2. JSX plugin → convert JSX → React.createElement() │ 3. Babel plugin → downgrade syntax (optional) │ 4. CSS Modules plugin → hash classnames, inject styles │ ▼ Resolve hook → for each import in the result, resolve path ▼ Plain JavaScript module (ready to link)

Common Transforms

Input Transform Output Tool
.ts / .tsx Strip types, compile TS Plain JS esbuild, tsc, SWC, Babel
.jsx Convert JSX syntax React.createElement() esbuild, Babel, SWC
.css Inject style tag / CSS Modules JS that inserts CSS at runtime css-loader, PostCSS
.scss / .sass Compile to CSS → inject JS + CSS sass, css-loader
.png / .jpg Inline (base64) or emit file URL string or data URI asset/resource, url-loader
.svg Inline as JSX component or URL React component or URL string @svgr/webpack, vite-plugin-svgr
.wasm Async load + instantiate Async import returning exports Built-in (Vite, webpack 5)
.json Inline as JS object literal export default { ... } Built-in all bundlers

The AST — Why It Matters

Transforms don't do string replacement. They parse source into an AST, modify the tree, then print it back to a string. This is why transforms are correct — they understand code structure, not just text patterns.

Source: const x: number = 5 AST: VariableDeclaration └── VariableDeclarator ├── Identifier (id: "x") ├── TSTypeAnnotation ← TypeScript plugin REMOVES this node │ └── TSNumberKeyword └── NumericLiteral (value: 5) Output: const x = 5

6. Tree Shaking

Tree shaking is the process of eliminating dead code — exports that exist in source but are never imported by anything reachable from the entry point. The name comes from shaking a tree and watching dead leaves fall.

How It Works

utils.ts export function add(a, b) { return a + b } ← USED export function subtract(a, b) { return a - b } ← NEVER IMPORTED export function multiply(a, b) { return a * b } ← NEVER IMPORTED main.ts import { add } from './utils' ← only add() is imported console.log(add(1, 2)) Output: subtract() and multiply() are NOT in the bundle.

The Mark-and-Sweep Algorithm

  1. 1
    Mark entry exports as "live" — everything the entry point uses is marked.
  2. 2
    Propagate liveness — if a live function calls other functions or imports from other modules, those are also marked live.
  3. 3
    Sweep — anything not marked live is excluded from the output.

What Breaks Tree Shaking

Pattern Why It Breaks Shaking Fix
import * as utils from './utils' Bundler must include ALL exports (namespace import) Use named imports: import { add }
Side effects in module body If a module does work when imported (e.g., registers a global), bundler can't remove it Mark package as "sideEffects": false in package.json
CJS format require() is dynamic — bundler can't statically determine what's used Use ESM, or a bundler plugin that converts CJS
Dynamic property access utils[someVar]() — bundler doesn't know which export is called Use direct named calls: utils.add()

"sideEffects": false in a library's package.json is a contract to the bundler: "none of my modules have side effects when imported." This unlocks aggressive tree shaking across the entire package. Always set this in library packages.

Scope Hoisting (Module Inlining)

A related optimization: instead of wrapping each module in a function closure (the old approach), Rollup and modern webpack flatten the module graph into a single scope. This makes the code smaller and faster — no function call overhead per module, and variable names can be further shortened by minification.

7. Code Splitting

Without code splitting, your entire app ships as one big JS file. The user must download ALL the code before anything runs — even code for pages they'll never visit.

Code splitting breaks the bundle into multiple files loaded on demand. The initial load only fetches what's needed to render the first screen.

Types of Code Splitting

A — Static Splitting (Multiple Entry Points)

// vite.config.ts / webpack.config.js
{
  build: {
    rollupOptions: {
      input: {
        main: 'src/main.ts',
        admin: 'src/admin/main.ts'   // separate entry → separate bundle
      }
    }
  }
}

Two entry points = two separate bundles. Used for multi-page apps where each page is completely independent.

B — Dynamic Splitting (Lazy Imports)

// React lazy route example
const HomePage = React.lazy(() => import('./pages/Home'))
const AboutPage = React.lazy(() => import('./pages/About'))

// When user navigates to /about, the browser fetches
// chunk-About-abc123.js on demand — not at initial load
Without code splitting: User loads /home → downloads: [main.js 3.2MB — includes ALL pages] With code splitting: User loads /home → downloads: [main.js 180KB] + [chunk-Home.js 42KB] User navigates to /about → downloads: [chunk-About.js 28KB] (on demand) User navigates to /dashboard → downloads: [chunk-Dashboard.js 95KB] (on demand) Initial load is 10x smaller.

C — Vendor Splitting

Separate your app code from npm dependencies. Libraries like React, lodash, and moment rarely change. Splitting them out means users cache vendor.js across deployments — they only re-download app.js when your code changes.

// vite.config.ts
{
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom', 'react-router-dom'],
          ui:     ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
        }
      }
    }
  }
}

8. Chunk Strategy

The bundler decides which modules go in which output file. This decision affects both initial load performance and how effectively the browser cache is used across deployments.

Content Hashing

Output filenames include a hash of the file contents: main.abc123.js. If the file doesn't change, the hash doesn't change. The browser's cache stays valid indefinitely. Only changed files get new hashes — and therefore new cache keys.

Deployment 1: main.a1b2c3.js → app code (changes every deploy) vendor.f9e8d7.js → react, react-dom (rarely changes) Deployment 2: Only you changed app code main.x4y5z6.js → NEW hash → browser downloads fresh copy vendor.f9e8d7.js → SAME hash → browser uses cache ✓ FREE

Shared Chunks

When module A and module B both import Button.tsx, the bundler doesn't duplicate it. Instead it extracts Button.tsx into a shared chunk that both lazy chunks depend on.

Without shared chunk (BAD): chunk-Home.js [Home + Button + Icon] 120KB chunk-About.js [About + Button + Icon] 110KB Button and Icon duplicated across chunks! With shared chunk (GOOD): chunk-Home.js [Home] 25KB chunk-About.js [About] 20KB chunk-ui.js [Button + Icon] 75KB ← shared, fetched once, cached

Prefetching & Preloading Chunks

The bundler can emit hints to the browser to prefetch chunks the user will likely need next, before they navigate there:

<!-- Generated by bundler in index.html -->
<link rel="prefetch" href="/chunk-About.abc123.js" />
<!-- Browser downloads this in background when idle -->
<!-- When user clicks "About", it's already in cache -->
🛠

Vite's automatic preload: Vite automatically generates <link rel="modulepreload"> tags for every chunk that the initial page load needs. This eliminates the waterfall for synchronous imports across chunks.

9. Minification & Optimization

Minification reduces file size without changing behavior. It's applied after all other transforms in production builds.

What Minifiers Do

Technique Example Savings
Remove whitespace function add ( a , b )function add(a,b) 10–20%
Shorten identifiers function calculateTotalPricefunction a 20–40%
Remove comments All // comments and /* blocks */ removed 5–15%
Constant folding const x = 2 + 2const x = 4 Small
Dead code elimination if (false) { ... } → removed entirely Varies
Inline small functions Replace call site with function body if tiny Small

Minifier Comparison

Tool Language Speed Compression Used By
Terser JavaScript Slow Best webpack (default)
esbuild Go Very fast Good Vite (default), esbuild
SWC Rust Very fast Very good Next.js, Parcel 2
OXC (oxc-minify) Rust Fastest Good Rolldown (upcoming Vite default)

Gzip & Brotli (Server-side Compression)

After minification, the server compresses files before sending them. Minified JS + Brotli compression typically achieves 75–85% size reduction vs the original source.

React app example: Source code: 2.4 MB After tree shaking: 820 KB After minification: 280 KB After Brotli: 78 KB ← what user actually downloads

10. Source Maps

After minification, your code looks like function a(b,c){return b+c}. When an error occurs in production, the stack trace points to this minified line — completely useless for debugging.

Source maps are separate files (main.js.map) that tell browser DevTools how to map minified code back to your original source, including file name and line number.

How Source Maps Work

main.js (minified, what browser runs): function a(b,c){return b+c} main.js.map (source map, what DevTools reads): { "version": 3, "sources": ["src/utils.ts"], "mappings": "AAAA,SAAS,GAAG,..." ← VLQ-encoded position mappings } DevTools shows: src/utils.ts:42 function add(a: number, b: number): number { return a + b }

Source Map Types

Type What It Does Use When
source-map Full source map in separate .map file Production — only loaded by DevTools, not users
inline-source-map Source map encoded as base64 at end of JS file Development or debugging — makes file very large
eval-source-map Source map in eval() per module Development — fastest rebuild, reasonable accuracy
hidden-source-map Generates .map file but no reference in JS Send to Sentry / error tracker but hide from users
false No source maps generated When you want to protect source code
⚠️

Security note: If you upload source maps to your production server publicly, anyone can read your original source code. Use hidden-source-map and upload maps only to your error tracking service (Sentry, Datadog) — not to your CDN.

11. Hot Module Replacement (HMR)

HMR is the dev server feature that updates your browser in real time when you save a file — without a full page reload. React component state is preserved, scroll position stays, only the changed module is swapped.

How HMR Works

  1. 1
    You save a file. The bundler detects the change via file system watcher (chokidar, fs.watch).
  2. 2
    The bundler re-processes only the changed module and its dependents (not the entire graph). This is the "hot path" — it's why Vite is so fast in dev: it recompiles one file, not everything.
  3. 3
    The dev server sends a WebSocket message to the browser: "Module X has a new version."
  4. 4
    The browser's HMR runtime receives the message, fetches the new module code, and runs the module's hot.accept() handler.
  5. 5
    The hot.accept() handler (provided by React Fast Refresh, Vue HMR, etc.) swaps the component in place without losing app state.
Vite HMR flow: You edit Button.tsx │ ▼ Vite re-transforms Button.tsx only (~5ms) ▼ Vite walks the import graph UP from Button.tsx │ → finds all modules that import Button.tsx │ → determines the HMR boundary (nearest module with hot.accept) ▼ Vite sends WebSocket: "{ type: 'update', modules: ['/src/Button.tsx'] }" ▼ Browser HMR runtime fetches: GET /src/Button.tsx?t=1234567890 ▼ React Fast Refresh swaps component in tree Result: UI updates in ~30ms, state preserved

Vite vs webpack HMR Speed

💡

Why Vite is fast: Webpack's dev server bundles all modules together even in dev mode. When you change one file, it re-bundles a large portion of the graph. Vite serves modules as native ES modules directly to the browser — no bundling at all in dev. Each file is served individually, so only the changed file needs reprocessing. HMR is proportional to the size of the changed file, not the project size.

12. Asset Handling

Everything that isn't JavaScript goes through asset handling: CSS, images, fonts, JSON, SVGs, WASM.

CSS Handling

When you import './styles.css' in JavaScript, the bundler needs to handle it. Two main strategies:

Strategy A — JS Injection

CSS is inlined into the JS bundle. At runtime, JS injects a <style> tag. No separate CSS file needed. Used by: webpack with style-loader, Vite dev mode.

Strategy B — Extract to File

CSS is extracted into a separate .css file. The browser loads JS and CSS in parallel. Better for production (no FOUC, cacheable separately). Used by: MiniCssExtractPlugin, Vite prod build.

CSS Modules

CSS Modules scope class names locally by hashing them. .button in Button.module.css becomes .button_abc123 — impossible to conflict with any other component's styles.

/* Button.module.css */
.button { background: blue; }

/* Button.tsx */
import styles from './Button.module.css'
<button className={styles.button}>Click me</button>

/* Output HTML */
<button class="button_3kf9a">Click me</button>

Image & Font Assets

The bundler processes asset files based on size thresholds:

  • Small files (<4KB) — inlined as base64 data URIs. Zero extra HTTP requests.
  • Large files (>4KB) — copied to output directory with content-hashed filename. Referenced by URL string in JS/CSS.
// vite.config.ts
{
  build: {
    assetsInlineLimit: 4096   // files smaller than 4KB → base64 inline
  }
}

13. Bundler Comparison

🐘
webpack 5
The veteran. JavaScript.
  • Most mature, largest ecosystem
  • Extremely configurable
  • Slowest build (JS single-threaded)
  • Module Federation (micro-frontends)
  • Complex config — steep learning curve
  • Used by: Create React App, Next.js (legacy)
🔵
Rollup
The library bundler. JS.
  • Best tree shaking via scope hoisting
  • Output: ESM, CJS, IIFE, UMD
  • Ideal for libraries / npm packages
  • Slower for apps with many assets
  • Vite uses Rollup for production builds
  • Plugin ecosystem: small but clean
esbuild
Blazing fast. Go.
  • 10–100x faster than webpack/Rollup
  • Written in Go — uses parallelism fully
  • Limited plugins compared to webpack
  • No full tree shaking (not Rollup-level)
  • Used by Vite as dev transformer
  • Good for: tools, CLIs, libraries
Vite
Modern app bundler. Hybrid.
  • Dev: native ESM (no bundling!) + esbuild
  • Prod: Rollup (best tree shaking)
  • Instant dev server start (no pre-bundle)
  • First-class React, Vue, Svelte support
  • Rolldown replacing Rollup in future
  • The modern default for new projects
🦀
Turbopack
Next-gen Rust bundler.
  • Written in Rust — parallel by design
  • Incremental computation graph (like Turborepo)
  • Native integration in Next.js 14+
  • Still maturing (not fully stable yet)
  • Targeting webpack replacement in Next.js
📦
Parcel 2
Zero-config. Rust core.
  • Zero config — just point at entry file
  • Rust core (SWC) + JS plugins
  • Automatic transforms (no loader config)
  • Good for: prototyping, small projects
  • Smaller ecosystem than webpack/Vite

When to Use What

Scenario Recommended Why
New React / Vue SPA Vite Best DX, fast HMR, modern defaults
Next.js app Turbopack (built-in) Integrated, optimized for Next.js
npm library / package Rollup or tsup Best tree shaking, clean ESM/CJS output
CLI tool / Node.js app esbuild Fastest, simple config, no browser quirks
Legacy project on webpack Stay on webpack Migration cost often outweighs benefit
Micro-frontends webpack 5 Module Federation is unique to webpack

14. Dev Mode vs Production Mode

The bundler behaves very differently depending on the mode. Dev prioritizes speed and debuggability; production prioritizes output size and runtime performance.

Feature Dev Mode Production Mode
Bundling None (Vite) or minimal bundling Full bundle with all optimizations
Tree shaking OFF ON
Minification OFF ON
Source maps Inline / eval (fast, full detail) Separate file or hidden
HMR ON OFF
Error messages Verbose, detailed Terse (minified code)
process.env.NODE_ENV "development" "production"
React Dev build (prop-types, warnings) Prod build (no warnings, smaller)
Output destination In-memory (dev server) Disk (dist/)
Build speed Optimized for fast incremental Slower, thorough optimization
🔥

process.env.NODE_ENV replacement: During bundling, the bundler replaces all occurrences of process.env.NODE_ENV with the literal string "production". Then the minifier sees if ("production" === "development") { ... } and removes the entire dead branch. This is how React's dev-only warnings disappear in production builds.

Mental Model — The Complete Picture

═══════════════════ YOUR SOURCE ═══════════════════ src/main.tsx (entry point) src/router.tsx (imports pages) src/pages/Home.tsx (lazy) src/pages/About.tsx (lazy) src/components/Button.tsx (shared) src/styles/global.css node_modules/react node_modules/lodash ═══════════════════ BUNDLER PIPELINE ═══════════════ 1. RESOLVE → Map all import strings to absolute file paths 2. LOAD → Read each file, route non-JS through transforms 3. PARSE → Build AST for each module 4. TRANSFORM → TS→JS, JSX→createElement, CSS→JS, etc. 5. GRAPH → Link all modules into directed dependency graph 6. SHAKE → Remove dead exports not reachable from entry 7. CHUNK → Split into main, vendor, and async chunks 8. MINIFY → Rename, compress, fold constants (PROD only) 9. EMIT → Write files to dist/ with content hashes ═══════════════════ OUTPUT (dist/) ══════════════════ index.html (with injected script tags) main.a1b2c3.js (entry chunk + sync imports) vendor.f9e8d7.js (react, react-dom — long cache) chunk-Home.d4e5f6.js (lazy: only loaded on /home) chunk-About.g7h8i9.js (lazy: only loaded on /about) shared-ui.j1k2l3.js (Button.tsx — shared between pages) global.m4n5o6.css (extracted CSS) main.a1b2c3.js.map (source map — only loaded by DevTools)

Quick Cheat Sheet

TermOne-liner
Entry pointThe starting file — bundler builds the entire graph from here
ASTTree representation of code that transforms operate on
Module graphAll discovered modules and their import relationships
Loader / PluginTransforms non-JS or extends bundler behavior for a file type
Tree shakingRemove exports that are never imported by anything reachable from entry
Code splittingBreak one bundle into many — some loaded eagerly, some lazily on demand
ChunkOne output JS file produced by the bundler
Content hashFilename includes hash of content — unchanged files = unchanged filename = cached forever
HMRDev feature: swap updated module in browser without full reload, preserving state
Source mapMapping from minified output back to original source lines — enables debugging
Scope hoistingFlatten module wrappers into one scope — smaller and faster at runtime
sideEffects: falsePackage.json signal: "no module in this package has global side effects" — enables aggressive shaking

References