Concept
A build tool does some combination of:
- Transpilation: TypeScript → JS, JSX → JS, newer JS → older JS
- Bundling: many modules → fewer files (reduces HTTP requests)
- Tree-shaking: remove exported code that's never imported
- Code splitting: break bundles into chunks loaded on demand
- Asset handling: images, CSS, fonts → processed and hashed
- HMR (Hot Module Replacement): update changed modules in the dev browser without full reload
esbuild, the foundation everything builds on
esbuild (2020, Evan Wallace) is written in Go and is 10, 100x faster than JavaScript-based tools. It's not primarily a dev server or full build system, it's a transformer and bundler.
// esbuild API
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/out.js',
format: 'esm',
minify: true,
sourcemap: true,
target: ['es2020'],
});What esbuild does well:
- TypeScript → JS stripping (no type checking, just removes types)
- JSX transformation
- Bundling + minification
- Tree shaking
What esbuild doesn't do:
- CSS Modules (limited support)
- HMR
- PostCSS / Sass
- TypeScript type checking (
tsc --noEmitseparately)
esbuild is used as the transformer inside Vite and Turbopack, they handle the dev server, HMR, and config on top of esbuild's speed.
Vite, the modern default
Vite (2020, Evan You) is the dev server and build tool that replaced Vue CLI and Create React App as the default for most projects.
Dev mode (the key insight): Vite doesn't bundle in development. It serves files as native ES modules directly to the browser. The browser's module system handles imports. Only the file you changed is reloaded, HMR is near-instant.
Dev server: browser requests /src/App.tsx
Vite transforms file on-demand (via esbuild)
Returns transformed module
No full bundle stepBuild mode: Uses Rollup under the hood for production bundling (better tree shaking and code splitting than esbuild's bundler at the time of writing).
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
},
},
},
});Plugin ecosystem: Vite's plugin API is a superset of Rollup's plugin API, most Rollup plugins work in Vite.
Pre-bundling: On first dev server start, Vite uses esbuild to pre-bundle node_modules (CommonJS → ESM, combine many small files). This is why the first start is slow; subsequent starts use cache.
Webpack, the battle-tested workhorse
Webpack (2012) is the oldest and most battle-tested bundler. It builds a dependency graph starting from entry points, applies loaders to transform files, and plugins to process the output.
// webpack.config.js
module.exports = {
entry: './src/index.ts',
output: { path: path.resolve(__dirname, 'dist'), filename: '[name].[contenthash].js' },
module: {
rules: [
{ test: /\.tsx?$/, use: ['babel-loader', 'ts-loader'] },
{ test: /\.css$/, use: ['style-loader', 'css-loader', 'postcss-loader'] },
{ test: /\.(png|jpg)$/, type: 'asset/resource' },
],
},
plugins: [
Loaders transform individual files (TypeScript, CSS, images). Plugins operate on the full bundle graph.
Why Webpack persists despite newer tools:
- Mature ecosystem: thousands of plugins
- Advanced code splitting control
- Module Federation (micro-frontends)
- Next.js used it (now migrating to Turbopack)
- Large enterprises have complex configs invested in it
Why Webpack is slow: It's JavaScript processing JavaScript. Each file goes through the Node.js loader chain. Large projects see 60, 120 second cold builds. Webpack 5 added persistent caching to mitigate this.
Rollup, library bundler
Rollup (2015) pioneered tree shaking for ES modules. It's the reference implementation for "build a library, not an app."
// rollup.config.js
export default {
input: 'src/index.ts',
output: [
{ file: 'dist/index.cjs.js', format: 'cjs' }, // CommonJS for Node.js
{ file: 'dist/index.esm.js', format: 'es' }, // ESM for bundlers
],
external: ['react', 'react-dom'], // don't bundle peer deps
plugins: [typescript(), terser()],
};Rollup produces cleaner output than Webpack for libraries, no runtime overhead, no module bootstrapping code. This is why every major library (React, Vue, Lodash) publishes Rollup-built artifacts.
Vite uses Rollup for production builds because Rollup's tree shaking and code splitting produce smaller, more optimised output than esbuild's bundler.
Turbopack, the Next.js successor
Turbopack (2022, Vercel) is written in Rust and designed as the successor to Webpack inside Next.js. Like esbuild, Rust's performance dwarfs JavaScript.
Key design: incremental computation. Turbopack builds a dependency graph and caches the result of every transformation. On change, only the affected subgraph is recomputed, not the entire bundle. This is similar to build systems like Bazel.
Currently: Turbopack dev server is stable in Next.js 15+ (next dev --turbopack). Production builds are in beta. The goal is to replace Webpack entirely in Next.js.
Which to choose
| Scenario | Tool |
|---|---|
| New React/Vue/Svelte app | Vite |
| Next.js | Turbopack (dev) / Webpack (build), transitioning |
| Publishing a library | Rollup (or tsup, which wraps esbuild) |
| Large existing codebase | Webpack (stay; migration cost is high) |
| Need maximum raw speed | esbuild directly |
| Monorepo with complex build graph | or (task orchestration) |
Common Mistakes
1. Using ts-loader + babel-loader together in Webpack
Two TypeScript transforms in series. Use babel-loader with @babel/preset-typescript for fast stripping, and run tsc --noEmit separately for type checking.
2. Not setting contenthash in Webpack output filenames
Without [contenthash], filenames don't change when content changes. Browsers serve the old cached file. Always use [name].[contenthash].js for long-term caching.
3. Importing all of lodash
import _ from 'lodash'; // bundles entire lodash (~70KB gzipped)
import get from 'lodash/get'; // bundles only get (~4KB)
import { get } from 'lodash'; // tree-shakable if ESM lodash-esUse lodash-es or per-function imports. Verify with a bundle analyzer.
4. Not analysing the bundle
webpack-bundle-analyzer or Vite's rollup-plugin-visualizer shows exactly what's in your bundle. Running this once often reveals 3, 5 quick wins (duplicate deps, huge libraries, unnecessary polyfills).
5. Over-configuring the dev server
HMR boundary misconfiguration causes full-page reloads instead of hot updates. In React: @vitejs/plugin-react (Babel) or @vitejs/plugin-react-swc (SWC) handle this. Don't fight the defaults.
Best Practices
- Use Vite for all new app projects. It's fast, well-supported, and has sensible defaults.
- Use Rollup (or tsup) for all library projects. tsup wraps esbuild + Rollup cleanly:
tsup src/index.ts --format esm,cjs --dts. - Analyse your bundle before shipping:
npx vite-bundle-visualizerornpx webpack-bundle-analyzer stats.json. - Use
manualChunksto control vendor splitting, separatereact/react-dominto a vendor chunk so it's cached independently from your app code. - Enable
build.sourcemap: true(or ) for production error tracking.
Performance Tips
- esbuild is the fastest transformer. If you're processing TS/JS in scripts or pre-commit hooks, use esbuild API directly.
- Vite's
optimizeDeps.include: pre-bundle deps that Vite would otherwise discover lazily (reduces waterfall on first dev load). - Webpack persistent cache (
cache: { type: 'filesystem' }) in Webpack 5 reduces cold build time from 60s to ~5s on subsequent builds. - Code splitting is the highest-impact production optimization, a single 2MB bundle that loads upfront becomes 5 × 200KB chunks loaded on demand. Vite does this automatically via dynamic
import().
