Skip to content

16 - Bundlers ​


Why Bundlers Exist ​

Browsers can load ES modules natively, but for production you still need a bundler:

  1. Fewer HTTP requests: 500 module files → 3-5 optimized chunks
  2. Tree shaking: Remove unused code from libraries
  3. Minification: Shrink variable names, remove whitespace
  4. Transpilation: Convert modern JS/TS/JSX to browser-compatible code
  5. Asset handling: Hash filenames for cache busting, inline small assets
  6. 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.css

Bundler 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:

js
// 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 authors

Rollup's strength: Produces the smallest, most readable bundles. Better tree shaking than Webpack because it was designed for ES modules from day one.

js
// 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
js
// 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.

bash
# Use Turbopack with Next.js
next dev --turbopack

SWC (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)
bash
# Use SWC instead of Babel in Vite
npm install -D @vitejs/plugin-react-swc
ts
// vite.config.ts
import react from '@vitejs/plugin-react-swc'; // drop-in replacement
export default defineConfig({ plugins: [react()] });

Comparison Table ​

WebpackRollupesbuildViteTurbopack
LanguageJSJSGoJS (uses esbuild+Rollup)Rust
Dev speedSlowN/A (no dev server)FastVery fastVery fast
Build speedSlowMediumVery fastFast (Rollup)Fast
Tree shakingGoodBestGoodBest (Rollup)Good
Code splittingBestGoodBasicGoodGood
Config complexityHighMediumLowLowNone (Next.js)
EcosystemLargestGoodSmallGrowingNext.js only
Best forLegacy appsLibrariesSpeed-criticalModern appsNext.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):

bash
npm run build
1. 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 asset

Key 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/components

Hashing ​

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 valid

Source 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 code

Enable in Vite:

ts
build: { sourcemap: true }
// Options: true (separate .map file), 'inline' (embedded), 'hidden' (for error tracking, not browser)

Frontend interview preparation reference.