JavaScriptBundlers (Webpack, Vite, esbuild)

JavaScript Bundlers

A bundler is a tool that takes your source files — dozens or thousands of small modules — and produces a small number of output files optimised for the browser. Behind the scenes it walks the dependency graph from an entry point, applies transforms, and concatenates the result. Modern bundlers do far more than concatenate: they tree-shake, split code, inline assets, and prepare hashed file names for cache busting.

Why bundle at all?
  • Modules in the browser. Native ES modules work in modern browsers, but loading hundreds of small files over the network is slow without HTTP/2 push and even then suboptimal for caching.

  • Transpilation. Source might be TypeScript, JSX or future JavaScript. The browser needs the compiled output.

  • Tree-shaking. Remove exports nobody imports — keep the shipped JS small.

  • Code splitting. Send only the JS the current page needs; lazy-load the rest.

  • Asset pipeline. Imports of CSS, images, SVG, fonts and JSON are processed alongside the code.

The dependency graph

Every bundler starts with the same idea. From an entry file, follow each import/require, collect every module reached, and emit them in dependency order. Anything that nothing reaches is dead code and can be dropped.

JS
// src/main.js
import { greet } from "./greet.js";
import "./styles.css";

greet("world");

From main.js the bundler discovers greet.js and styles.css. It emits a JS bundle containing the JS modules and a CSS bundle for the styles, both hashed for cache busting.

Tree-shaking

When the bundler can statically determine which exports are used, it drops the rest. This works best with ES modules (import/export) because their imports are static — require(./${name}) defeats the analysis.

JS
// utils.js
export function used(x)   { return x + 1; }
export function unused(x) { return x - 1; }   // dropped

// main.js
import { used } from "./utils.js";
console.log(used(2));
sideEffects in package.json
Set `"sideEffects": false` so a bundler can drop entire files. Set it to an array (e.g. `["./polyfills.js"]`) to keep specific modules that must run for their effects.
Code splitting

Dynamic import() creates a split point. Anything beyond it goes into a separate chunk, fetched only when that path runs.

JS
button.addEventListener("click", async () => {
  const { Chart } = await import("./chart.js");
  Chart.render();
});

The initial page bundle stays small. ./chart.js and its dependencies are downloaded on first click, not on first load.

Webpack

The original heavyweight. Mature, configurable, supports everything via loaders and plugins. Configuration via webpack.config.js. The cost: complex config, slower builds compared to newer tools.

  • Best fit for very large legacy projects where every loader and plugin is already wired up.

  • Dev server with hot module replacement is solid but slower to start than Vite.

  • Module Federation makes Webpack the go-to for micro-frontends.

Vite

A modern dev/build tool. In development, Vite serves source files as native ES modules — no bundling at all — and only transforms what the browser asks for. For production it uses Rollup. The result: instant dev server start, fast HMR, and clean optimised builds.

  • Default choice for new SPAs and library projects.

  • First-class TypeScript, JSX, CSS modules, Vue, React, Svelte.

  • Plugin API shared with Rollup — a deep ecosystem reuses both.

Rollup

Originally aimed at libraries. Produces clean, near-source output thanks to strict ES module semantics. Great tree-shaking. Used inside Vite for production builds.

  • Best fit for publishing a library that should produce multiple output formats (ESM, CJS, UMD).

  • Smaller plugin ecosystem than Webpack, but most of what you need is there.

esbuild

Written in Go. Astonishingly fast — order-of-magnitude faster than the others. Less plugin ecosystem; intentionally a smaller surface. Used as the transpiler under Vite, and as a standalone tool for build pipelines that prize speed.

  • Best fit for a build step inside another tool, or for a project that wants the simplest possible config.

  • No HMR of its own (you usually pair it with another tool).

  • Excellent TypeScript transpilation (syntax only — no type checking).

Picking one
  • Building a new web app? Vite.

  • Maintaining a large project already on Webpack? Stay on Webpack until there is a reason to migrate.

  • Publishing a library to npm? Rollup (or Vite's library mode).

  • Writing a CLI or quick build script? esbuild.

  • Bun, Parcel and Turbopack are credible options too — try them if their constraints fit yours.

What a bundler does NOT do
  • It does not check types — that is TypeScript's job.

  • It does not test code — that is Jest/Vitest/Playwright.

  • It does not lint — that is ESLint.

  • It is a pipeline of transforms; everything else is plugins on top of it.

The mental model
A bundler is a graph walker plus a transform pipeline plus a chunk emitter. Configuration mostly tells it (a) where to start, (b) how to transform each file, and (c) how to split the output. Once you see the pipeline, every bundler looks like a variation on the same idea.