How Modern JavaScript Bundlers Work
From entry point to a single optimized file. The parse-graph-transform-bundle pipeline explained with real code, including tree shaking, code splitting, and source maps.
Every modern web app ships through a bundler. You write dozens of modules with nice imports, and you ship one (or a few) optimized files. The tool that does this — webpack, Rollup, esbuild, Vite, Turbopack — is the invisible machinery of frontend development. But almost nobody knows what actually happens between npm run build and the output. This article walks through the pipeline with real examples.
The four stages
Every bundler, regardless of flavor, runs the same four-stage pipeline:
- Parse — turn source files into ASTs
- Build the module graph — resolve imports and connect files
- Transform — compile each module (TS→JS, JSX→JS, modern→compatible)
- Bundle — concatenate and optimize, with code splitting and minification
Stage 1–2: The module graph
Start from your entry point:
// src/index.js
import { render } from "./app.js";
import "./styles.css";
render(document.getElementById("root"));
The bundler parses index.js, finds the import of ./app.js, parses that, and follows its imports too. The result is a graph — every file, connected by dependency edges.
index.js ──→ app.js ──→ ui.js
│ └─→ icons.js
└─→ api.js ──→ http.js
Each node in the graph is a module. The bundler assigns every module an integer ID and produces a lookup:
// After graph construction, roughly:
const modules = {
0: (module, exports, require) => { /* index.js */ },
1: (module, exports, require) => { /* app.js */ },
2: (module, exports, require) => { /* ui.js */ },
};
Stage 3: Transform
Before the modules can be concatenated, each one is compiled. This is where the ecosystem sits: Babel compiles modern syntax, esbuild and SWC are faster native compilers, and each bundler has loaders/transforms for TypeScript, JSX, CSS, and assets.
// Input (TypeScript)
const greet = (name: string): string => `Hello, ${name}!`;
// Output (after transform)
const greet = (name) => `Hello, ${name}!`;
The key insight: the transform happens per-module, in parallel, before any bundling. This is why esbuild (written in Go) is so much faster than a JS-based bundler — parallel native code beats a single-threaded JS loop.
Stage 4: Bundle — modules are just functions
The heart of the trick: after transforms, each module is wrapped in a function, and the bundler writes a tiny runtime require that looks modules up by ID. A “bundle” is literally an IIFE with all your modules inside.
// Simplified bundled output
(function () {
var modules = {
0: function (module, exports, require) {
var app = require(1);
app.render(document.getElementById("root"));
},
1: function (module, exports, require) {
function render(el) { el.innerHTML = "<h1>Hello</h1>"; }
exports.render = render;
},
};
var cache = {};
function require(id) {
if (cache[id]) return cache[id].exports;
var module = (cache[id] = { exports: {} });
modules[id](module, module.exports, require);
return module.exports;
}
require(0);
})();
Your development code, transformed and wrapped, shipped in a single callable scope. That’s what’s in the 400KB file you ship.
Tree shaking: removing dead code
Tree shaking (a Rollup concept) removes exported-but-unused code. It only works because bundlers can prove a module’s exports are unused — and it only works with ES modules (import/export), because their static structure allows analysis. CommonJS’s dynamic require is unknowable statically, so it can’t be shaken.
// utils.js
export function used() { return "used"; }
export function unused() { return "never imported"; }
// entry.js
import { used } from "./utils.js";
console.log(used());
The bundler sees unused is never referenced, so the output contains only used. This is why library authors are told to ship ES modules — sideEffects: false in package.json tells bundlers your package is safe to shake.
Code splitting: shipping less on first load
Splitting breaks your bundle into chunks loaded on demand. The router-based approach is the most common:
const routes = [
{ path: "/", component: () => import("./Home.js") },
{ path: "/settings", component: () => import("./Settings.js") },
];
The bundler splits Settings.js into its own chunk, loaded only when someone visits /settings. The main bundle stays small; first paint stays fast.
entry.js ── inlined imports that are needed immediately
chunk-123 ── async chunk for /settings (loaded on navigation)
The import() boundary is the only thing bundlers can’t decide for you — you choose what’s critical by choosing what’s static vs dynamic.
Source maps: undoing the damage
All that transformation destroys your debuggability. Source maps restore it. A source map is a JSON file mapping bundled output positions back to original source positions:
// # sourceMappingURL=app.js.map
{"version":3,"sources":["index.ts","app.ts"],"mappings":"AAAA,..."}
Modern browsers load the map lazily and show you original TypeScript in DevTools, with working breakpoints. Maps must be served (never ship them to production clients unless you’re comfortable exposing source) and they’re the reason “minified error in console” is a solvable problem.
Why your build is slow (and how to fix it)
The bundler’s bottleneck is usually one of:
- Single-file bottlenecks: one massive component file can’t be parallelized
- Slow transforms: TypeScript type-checking is not transpiling; don’t type-check in the bundler
- No caching: esbuild/SWC and webpack’s persistent cache skip re-work
- Too many chunks: hundreds of tiny chunks thrash I/O
Practical fixes: switch heavy transforms to esbuild or SWC, use the bundler’s persistent cache, and profile with --profile on webpack / vite build --debug.
Conclusion
A bundler is a four-stage pipeline: parse, graph, transform, bundle. Understand where each stage happens, and the “magic” evaporates — tree shaking is just static analysis, code splitting is just dynamic imports, and the runtime is just a tiny require function. When your build gets slow or your bundle gets fat, you now know exactly which stage to blame.
Written by
Benmalek Zohir
Founder, AI Engineer & Full Stack Developer
Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.