16 - Bundlers ​
Why Bundlers Exist ​
Browsers can load ES modules natively, but for production you still need a bundler:
- Fewer HTTP requests: 500 module files → 3-5 optimized chunks
- Tree shaking: Remove unused code from libraries
- Minification: Shrink variable names, remove whitespace
- Transpilation: Convert modern JS/TS/JSX to browser-compatible code
- Asset handling: Hash filenames for cache busting, inline small assets
- Code splitting: Separate vendor code from app code for better caching
How Bundling Works (Conceptual) ​
Step 1: Entry Point
main.tsx is the starting file
Step 2: Dependency Graph
Bundler follows every import/require:
main.tsx → App.tsx → Button.tsx → utils.ts
→ Header.tsx → Logo.png
→ index.css
Result: a full graph of every file the app needs
Step 3: Transform
Each file is processed by "loaders" or "plugins":
.tsx → JSX transform + TypeScript strip → .js
.css → extract to file or inject as <style>
.png → copy to dist/ with hashed name or inline as base64
Step 4: Bundle
Combine transformed files into chunks:
- main chunk (your code)
- vendor chunk (node_modules)
- async chunks (lazy-loaded routes)
Step 5: Optimize
- Tree shake (remove dead code)
- Minify (terser/esbuild)
- Hash filenames (cache busting)
- Generate source maps
- Generate HTML with correct script tags
Step 6: Output
dist/
├── index.html
├── assets/
│ ├── index-a1b2c3.js
│ ├── vendor-d4e5f6.js
│ └── index-g7h8i9.cssBundler Comparison ​
Webpack ​
Age: 2012+ (most mature)
Language: JavaScript
Speed: Slowest (JS-based transforms)
Config: Complex (webpack.config.js can be hundreds of lines)
Ecosystem: Largest — loaders and plugins for everything
Dev server: webpack-dev-server (bundles everything, then serves)
Used by: Create React App (deprecated), Next.js (migrating to Turbopack)How Webpack works:
// webpack.config.js
module.exports = {
entry: './src/index.tsx',
output: { filename: '[name].[contenthash].js', path: path.resolve(__dirname, 'dist') },
module: {
rules: [
{ test: /\.tsx?$/, use: 'ts-loader' }, // TypeScript
{ test: /\.css$/, use: ['style-loader', 'css-loader'] }, // CSS
{ test: /\.(png|svg)$/, type: 'asset/resource' }, // Images
],
},
plugins: [
new HtmlWebpackPlugin({ template: './index.html' }),
],
};Webpack's model: Everything goes through "loaders" (file transforms) and "plugins" (build lifecycle hooks). Powerful but verbose.
Rollup ​
Age: 2015+
Language: JavaScript
Speed: Medium
Config: Simpler than Webpack
Ecosystem: Good — focus on library bundling
Output: Cleanest code (designed for libraries, tree shakes aggressively)
Used by: Vite (production builds), library authorsRollup's strength: Produces the smallest, most readable bundles. Better tree shaking than Webpack because it was designed for ES modules from day one.
// rollup.config.js
export default {
input: 'src/index.ts',
output: [
{ file: 'dist/bundle.cjs', format: 'cjs' }, // CommonJS
{ file: 'dist/bundle.mjs', format: 'es' }, // ESM
],
plugins: [resolve(), commonjs(), typescript()],
};When to use Rollup directly: Building a library/SDK (not an app).
esbuild ​
Age: 2020+
Language: Go (compiled, native speed)
Speed: 10-100x faster than Webpack/Rollup
Config: Minimal
Ecosystem: Small (fewer plugins)
Output: Good but not as optimized as Rollup
Used by: Vite (dependency pre-bundling), tsx (TS runner), tsup (library bundler)Why it's so fast:
- Written in Go (compiled, not interpreted JS)
- Parallel processing (Go's goroutines)
- Minimal AST passes (does parsing, transforms, and code gen in fewer steps)
- No intermediate representations
// esbuild API
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.tsx'],
bundle: true,
minify: true,
outfile: 'dist/bundle.js',
target: 'es2020',
loader: { '.tsx': 'tsx', '.css': 'css' },
});Limitation: Code splitting support is less mature than Rollup. That's why Vite uses esbuild for dev transforms but Rollup for production builds.
Turbopack ​
Age: 2022+ (by Vercel, successor to Webpack)
Language: Rust
Speed: Comparable to esbuild (claims faster in incremental builds)
Config: Integrated into Next.js (no separate config)
Used by: Next.js 13+ (opt-in with --turbo flag)Key innovation: Incremental computation. It caches the result of every function call. On a rebuild, only re-runs functions whose inputs changed.
# Use Turbopack with Next.js
next dev --turbopackSWC (Speedy Web Compiler) ​
Not a bundler — it's a COMPILER (like Babel but in Rust)
Replaces: Babel (JSX transform, TS strip, minification)
Speed: 20x faster than Babel
Used by: Next.js (default compiler), Vite (optional via @vitejs/plugin-react-swc)# Use SWC instead of Babel in Vite
npm install -D @vitejs/plugin-react-swc// vite.config.ts
import react from '@vitejs/plugin-react-swc'; // drop-in replacement
export default defineConfig({ plugins: [react()] });Comparison Table ​
| Webpack | Rollup | esbuild | Vite | Turbopack | |
|---|---|---|---|---|---|
| Language | JS | JS | Go | JS (uses esbuild+Rollup) | Rust |
| Dev speed | Slow | N/A (no dev server) | Fast | Very fast | Very fast |
| Build speed | Slow | Medium | Very fast | Fast (Rollup) | Fast |
| Tree shaking | Good | Best | Good | Best (Rollup) | Good |
| Code splitting | Best | Good | Basic | Good | Good |
| Config complexity | High | Medium | Low | Low | None (Next.js) |
| Ecosystem | Largest | Good | Small | Growing | Next.js only |
| Best for | Legacy apps | Libraries | Speed-critical | Modern apps | Next.js apps |
What Happens in a Production Build (Step by Step) ​
Using Vite as the example (since that's what you'll use in interviews):
npm run build1. Vite reads vite.config.ts
2. Resolves entry: index.html → finds <script src="/src/main.tsx">
3. Builds dependency graph from main.tsx following all imports
4. Transforms each file:
- .tsx → strip types, transform JSX → .js (via esbuild or SWC)
- .css → extract into separate file
- Images → copy to dist/ with content hash
5. Rollup bundles:
- Tree shakes unused exports
- Splits code (route-based chunks, manual chunks)
- Generates import maps for chunk loading
6. Minifies all JS (esbuild minifier — faster than terser)
7. Minifies all CSS
8. Generates hashed filenames: index-a1b2c3.js
9. Generates source maps (if enabled)
10. Writes everything to dist/
11. Updates index.html with correct script/link tags
Output:
dist/
├── index.html # Updated with hashed asset URLs
├── assets/
│ ├── index-Bk3a8x.js # App code (~50KB)
│ ├── vendor-Dk29Lm.js # React + dependencies (~140KB)
│ ├── Dashboard-x8k2P.js # Lazy-loaded route chunk (~30KB)
│ ├── index-Qp4r1N.css # Extracted CSS (~10KB)
│ └── logo-hK29x.png # Hashed static assetKey Concepts ​
Entry Points ​
The starting file(s). Bundler traces ALL imports from here.
Single entry: main.tsx → one bundle (most SPAs)
Multiple entries: admin.tsx, checkout.tsx → separate bundles (multi-page)Chunks ​
A "chunk" is a separate JS file in the output.
Main chunk: your app code
Vendor chunk: node_modules (react, zustand, etc.)
Async chunks: lazy-loaded routes/componentsHashing ​
Content-based hash in filename for cache busting.
index-a1b2c3.js → content changes → index-x7y8z9.js (new hash)
Browser knows: different filename = new content, refetch
Same filename = cached version is still validSource Maps ​
Maps minified code back to original source for debugging.
dist/index-a1b2c3.js → minified, unreadable
dist/index-a1b2c3.js.map → maps to src/App.tsx:42
Browser DevTools uses the map → you debug original codeEnable in Vite:
build: { sourcemap: true }
// Options: true (separate .map file), 'inline' (embedded), 'hidden' (for error tracking, not browser)