Bundlers: Vite, webpack & esbuild
Modern JavaScript apps are written across dozens or hundreds of small modules, pull in dependencies from npm, and use syntax or file types the browser does not understand natively. A bundler stitches all of that into a small set of optimized files the browser can actually load fast. Understanding what a bundler does—and which one to reach for—is essential to shipping production-grade frontends.
Why bundling exists
Browsers can load ES modules directly with <script type="module">, but doing so naively means one network request per file. With a deep dependency graph, that is hundreds of round trips, plus the browser cannot run TypeScript, JSX, Sass, or .vue files. A bundler solves three problems at once:
- Module resolution. It follows
import/requirestatements, builds a dependency graph, and merges modules into bundles. - Transforms. It runs your code through loaders/plugins to compile TypeScript, JSX, CSS, and assets into browser-ready output.
- Optimization. It removes dead code, splits bundles for caching, and minifies the result.
// Source: many small modules
import { formatPrice } from './currency.js';
import { Cart } from './cart.js';
export const total = (items) => formatPrice(new Cart(items).sum());
The bundler reads that graph and emits something like assets/index-a1b2c3.js—one cacheable, optimized file.
The core optimizations
Tree-shaking
Tree-shaking is dead-code elimination based on static import/export analysis. If you import one function from a library, only that function (and its dependencies) ends up in the bundle.
// utils.js
export const used = (x) => x * 2;
export const unused = (x) => x / 0;
// app.js — only `used` survives bundling
import { used } from './utils.js';
console.log(used(21));
Output:
42
Tree-shaking only works on ES modules (
import/export). CommonJSrequire()is dynamic, so bundlers often cannot prune it. Prefer packages that ship an ESM build.
Code-splitting
Rather than one giant file, the bundler can split rarely used code into separate chunks loaded on demand via dynamic import(). This shrinks the initial payload.
button.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart(data);
});
The charting code is fetched only when the user clicks—a separate chunk, cached independently.
Minification
Minifiers strip whitespace, shorten variable names, and fold constants, often cutting bundle size by 40-60% before gzip/brotli compression on top.
Comparing the major tools
The ecosystem has consolidated around a few players. esbuild and SWC are ultra-fast Go/Rust compilers; Rollup is the gold standard for library output; webpack is the mature, plugin-rich veteran; and Vite ties fast compilers together into a great dev experience.
| Tool | Written in | Best for | Dev story | Notes |
|---|---|---|---|---|
| Vite | JS + esbuild/Rollup | Apps (SPAs, SSR) | Native ESM dev server, instant HMR | Uses esbuild in dev, Rollup for prod builds |
| webpack | JavaScript | Large/legacy apps, huge plugin needs | Bundles before serving (slower) | Most mature ecosystem and loaders |
| esbuild | Go | Speed-critical builds, simple bundles | Extremely fast, fewer features | Powers many other tools under the hood |
| Rollup | JavaScript | Libraries/packages | N/A (build tool) | Clean ESM output, great tree-shaking |
Vite wins on developer experience: in dev it serves source files over native ESM and transforms them on demand with esbuild, so startup is near-instant regardless of project size. webpack wins on ecosystem breadth—if a niche loader exists, it exists for webpack. esbuild wins on raw speed but offers fewer features (e.g., limited CSS handling, no built-in HMR), which is why it is often a building block rather than the whole toolchain.
A tiny Vite example
Vite needs almost no configuration to start. Scaffold a project, then run the dev server.
npm create vite@latest my-app -- --template vanilla
cd my-app
npm install
npm run dev
A minimal vite.config.js shows how plugins and build options plug in:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
target: 'es2020',
minify: 'esbuild',
rollupOptions: {
output: {
// Split node_modules into a long-cached vendor chunk
manualChunks: { vendor: ['lodash-es'] },
},
},
},
});
Your index.html is the entry point—Vite crawls the <script type="module"> tag and follows imports from there.
<!doctype html>
<html lang="en">
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
// src/main.js
const app = document.querySelector('#app');
app.textContent = `Bundled at ${new Date().toISOString()}`;
Running npm run build produces a hashed, minified, tree-shaken dist/ ready to deploy to any static host.
Output:
vite v5.x building for production...
✓ 12 modules transformed.
dist/index.html 0.32 kB │ gzip: 0.24 kB
dist/assets/index-a1b2c3.js 1.41 kB │ gzip: 0.78 kB
✓ built in 240ms
Best Practices
- Choose Vite for new apps and webpack only when you depend on its specific plugin ecosystem; use Rollup for publishing libraries.
- Ship and consume ESM packages so tree-shaking can actually remove unused code.
- Use dynamic
import()to code-split heavy, route-level, or interaction-only features. - Keep stable dependencies in a separate vendor chunk so app changes do not bust their cache.
- Let the bundler hash filenames (
index-a1b2c3.js) for long-term immutable caching. - Measure bundle size with a visualizer (e.g.
rollup-plugin-visualizer) before optimizing blindly. - Set a realistic
build.targetso the bundler does not over-transpile for browsers you do not support.