Skip to content
JavaScript js tooling 4 min read

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/require statements, 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). CommonJS require() 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.

ToolWritten inBest forDev storyNotes
ViteJS + esbuild/RollupApps (SPAs, SSR)Native ESM dev server, instant HMRUses esbuild in dev, Rollup for prod builds
webpackJavaScriptLarge/legacy apps, huge plugin needsBundles before serving (slower)Most mature ecosystem and loaders
esbuildGoSpeed-critical builds, simple bundlesExtremely fast, fewer featuresPowers many other tools under the hood
RollupJavaScriptLibraries/packagesN/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.target so the bundler does not over-transpile for browsers you do not support.
Last updated June 1, 2026
Was this helpful?