Bundlers & build tools
Learn why JavaScript bundlers exist, how Vite and webpack build module graphs, and how tree shaking, hashes, and source maps ship faster code.
- 01Explain the bundle jobConnect module requests, bare specifiers, transforms, minification, and content hashes to the files users download.
- 02Trace a module graphParse imports, order dependencies, emit a small registry bundle, and run the generated output.
- 03Debug production buildsUse tree shaking, dynamic imports, source maps, and tool choices without hiding runtime trade-offs.
What a bundler does
A modern JavaScript app is rarely one file. It may import your components, helper modules, CSS, images, npm packages, and code that should load only after a user clicks. A bundler follows that web of imports before deployment and writes files the browser can fetch efficiently.
A bundler starts from one or more entry modules, builds a module graph, transforms files when needed, and emits browser-ready chunks. Build tools such as Vite, webpack, Rollup, esbuild, Turbopack, and Rspack wrap that job with development servers, plugins, minifiers, source maps, and production defaults.
A store does not send a customer to ten shelves. It uses an order, gathers the items, packs related things together, and puts a tracking number on the shipment. A bundler does the same kind of preparation for modules.
- In real life: Customer order
- In JavaScript: The app entry module, such as
/src/main.js - In real life: Packing list
- In JavaScript: The dependency graph discovered from imports
- In real life: Boxes combined into shipments
- In JavaScript: Output chunks that load together
- In real life: Tracking numbers
- In JavaScript: Content hashes such as
app.a1b2.js
Where the analogy stops: A warehouse moves physical boxes. A bundler rewrites source text and can change file boundaries; it should not change the meaning of your program.
This lesson builds on why modules exist, import and export, module resolution, and npm and package.json. We will also connect to dynamic import and CommonJS vs ESM.
Why bundle
REQUESTS TO OUTPUTNative ES modules are excellent, but a production app still has practical problems. Loading hundreds of tiny files can create a waterfall: the browser fetches the entry, discovers its imports, fetches those, then discovers deeper imports. npm packages also use bare specifiers such as lodash-es, which are not URLs unless an import map defines them.
| Reason | What the bundler does |
|---|---|
| Many small modules | Native module loading can create request waterfalls: main.js loads, then its imports, then their imports. |
| Bare specifiers | import { debounce } from "lodash-es" is not a URL. Browsers need an import map or a tool that rewrites it. |
| Transforms | TypeScript, JSX, CSS modules, Sass, and asset imports need conversion before many browsers can run them. |
| Optimization | Bundlers can minify, remove unused exports, split lazy chunks, and add content hashes for long-lived caching. |
A bundler can also run transforms: TypeScript to JavaScript, JSX to function calls, CSS imports to real CSS files, and asset imports to URLs. After that it can minify, tree-shake, split lazy chunks, and add content hashes so unchanged files can stay cached for months.
<script type="importmap">{ "imports": { "lodash-es": "https://cdn.example/lodash-es/index.js" }}</script><script type="module"> import { chunk } from "lodash-es"; console.log(chunk([1, 2, 3, 4], 2));</script>A tiny site with a few modules, no JSX or TypeScript, no CSS imports, and a small import map can ship native ESM directly. The module resolution lesson explains the browser side of that choice. Most applications eventually want transforms, hashing, chunk splitting, or dependency optimization.
The module graph: entry to dependencies
STEP THROUGHThe entry is the first file a build starts from. Every static import points to another file, and each of those files can import more. The result is a graph, not just a list, because several modules can share one dependency. A bundler must visit dependencies before it emits the module that reads them.
The replay below shows the topological ordering step. The real lesson function gets the import map by parsing four in-memory ES modules with acorn; the visible trace starts after parsing so the ordering stays readable.
Step through the module graph after acorn has found the import declarations. The important shape is entry to dependencies to bundled order.
script
["/src/main.js", ["/src/setup.js", "/src/math.js", "/src/format.js"]], ["/src/format.js", ["/src/math.js"]], ["/src/math.js", []], ["/src/setup.js", []],]); const seen = new Set();const order = [];function visit(id) { if (seen.has(id)) return; seen.add(id); for (const dependency of imports.get(id)) visit(dependency); order.push(id);} visit("/src/main.js");console.log(order.join(" -> "));Build a real tiny bundler
ACORN + OUTPUTThe lesson's function keeps four source strings in memory: main.js, setup.js, math.js, and format.js. It parses each as an ES module, records imports and exports, orders the graph, rewrites imports into a tiny require registry, and emits one runnable bundle.
import { parse } from "acorn"; function importsOf(id, source) { const ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }); return ast.body .filter((node) => node.type === "ImportDeclaration") .map((node) => resolveSpecifier(id, node.source.value));} function visit(id) { if (seen.has(id)) return; seen.add(id); for (const dependency of importsOf(id, files[id])) visit(dependency); order.push(id);}Line by line, the important moves are small. Parse with sourceType: "module" so imports and exports are real syntax nodes. Resolve each relative specifier from the current file. Visit dependencies first. Finally, push the current id into the output order.
(function(modules) { const cache = {}; function require(id) { if (cache[id]) return cache[id].exports; const module = { exports: {} }; cache[id] = module; modules[id](require, module, module.exports); return module.exports; } require("/src/main.js");})({ "/src/setup.js": function(require, module, exports) { console.log("setup side effect"); }, "/src/math.js": function(require, module, exports) { const version = "1.0.0"; function add(a, b) { return a + b; } exports.add = add; exports.version = version; }, "/src/format.js": function(require, module, exports) { const { version: version } = require("/src/math.js"); function label(value) { return "v" + version + ": " + value; } exports.label = label; }, "/src/main.js": function(require, module, exports) { require("/src/setup.js"); const { add: add } = require("/src/math.js"); const { label: label } = require("/src/format.js"); console.log(label(add(2, 3))); }});setup side effectv1.0.0: 5The generated code is plain JavaScript. It creates a module registry, runs /src/main.js, and prints the same output the tests assert.
The generated bundle is deliberately old-fashioned: an object maps ids to functions, and a small require creates module.exports, caches the module, and runs the entry. Real bundlers emit more efficient code, but this proves the core idea without hiding it behind a tool.
Tree shaking: keep reachable exports
USED EXPORTSTree shaking is dead-code removal guided by the module graph. ESM is helpful because static import and export names are visible before the module runs. Start at the entry, mark the imported exports it reads, follow those declarations to their imports, and drop exported declarations that never become reachable.
Tree shaking starts at the entry, marks the exports that are reachable, and emits only those declarations.
script
"math.version", "math.add", "math.multiply", "format.label", "format.loud",]; const used = new Set(["math.add", "format.label"]);used.add("math.version"); const kept = exportsInGraph.filter((name) => used.has(name));const dropped = exportsInGraph.filter((name) => !used.has(name));console.log(kept.join(", "));console.log(dropped.join(", "));(function(modules) { const cache = {}; function require(id) { if (cache[id]) return cache[id].exports; const module = { exports: {} }; cache[id] = module; modules[id](require, module, module.exports); return module.exports; } require("/src/main.js");})({ "/src/setup.js": function(require, module, exports) { console.log("setup side effect"); }, "/src/math.js": function(require, module, exports) { const version = "1.0.0"; function add(a, b) { return a + b; } exports.add = add; exports.version = version; }, "/src/format.js": function(require, module, exports) { const { version: version } = require("/src/math.js"); function label(value) { return "v" + version + ": " + value; } exports.label = label; }, "/src/main.js": function(require, module, exports) { require("/src/setup.js"); const { add: add } = require("/src/math.js"); const { label: label } = require("/src/format.js"); console.log(label(add(2, 3))); }});/src/setup.js -> /src/math.js -> /src/format.js -> /src/main.jsmath.js.multiply, format.js.loudsetup side effect | v1.0.0: 5The side-effect import is preserved, while unused named exports are removed. Importing multiply grows the bundle and output.
{ "name": "tiny-ui-kit", "sideEffects": false}"sideEffects": false is a promise: importing files only for top-level effects is safe to remove when their exports are unused. If a package lies, CSS imports, registrations, or monkey patches can vanish. A /*#__PURE__*/ annotation is narrower: it tells a minifier that one call can be removed if its result is unused.
const button = /*#__PURE__*/ createButton({ variant: "ghost" });export const used = button.label;export const unused = /*#__PURE__*/ createChartTheme();CommonJS resists this because require() can be dynamic and module.exports can be mutated at runtime. Tools still optimize common patterns, but ESM gives them a more reliable static shape.
Dynamic import creates chunks
LAZY LOADA static import belongs to the current graph. A dynamic import() returns a promise and marks a split point. Bundlers can emit a separate chunk for the imported module and its dependencies, so heavy code waits until the user reaches that interaction.
const button = document.querySelector("#show-chart"); button.addEventListener("click", async () => { const { renderChart } = await import("./charts.js"); renderChart();});Production output usually names chunks with content hashes. If the chart code changes, the chart chunk gets a new filename; unchanged chunks keep their old URL and stay cached. The dynamic import lesson focuses on the language feature itself.
Vite, esbuild, Rollup, and webpack
CURRENT FACTSVite is a build tool and development server. During development it serves native ESM, transforms files on demand, and pre-bundles dependencies so packages with many internal files do not slow every page load. For production, Vite historically used Rollup while esbuild handled many fast transforms.
Vite 7 was announced on June 24, 2025 and described rolldown-vite as a drop-in path while Rolldown would become the default later. Vite 8 was announced on March 12, 2026 with Rolldown as the default bundler for development and production. If you are reading years later, check the Vite release notes for the newest default.
| Tool | Main role | Mental model |
|---|---|---|
| Vite | Dev server plus production build pipeline | Serves native ESM during development, transforms on demand, pre-bundles dependencies, and in Vite 8 uses Rolldown by default. |
| esbuild | Very fast parser, transformer, and bundler | Written in Go; historically used by Vite for dependency pre-bundling and dev transforms because it is extremely fast. |
| Rollup | ESM-first production bundler | Great output for libraries and apps, with a mature plugin API and tree-shaking model. |
| webpack | Configurable application bundler | Models entry, output, loaders, plugins, modes, chunks, and HMR for large app ecosystems. |
esbuild is written in Go and is famous for speed. Rollup is ESM-first with a mature plugin API and excellent library output. Vite builds on ecosystem pieces so you can get fast development feedback and optimized production output without writing a full bundler config from scratch.
webpack concepts, Next.js, Turbopack, and Rspack
VOCABULARYwebpack's vocabulary is still everywhere. Even when a framework hides the config, you will hear about entries, loaders, plugins, chunks, and HMR. Next.js, which builds this site, owns the app framework layer and can use bundlers underneath it. Turbopack and Rspack are Rust-based projects that aim to keep familiar workflows while making builds faster.
| Concept | What it means |
|---|---|
| entry | The starting file or files webpack follows to build the graph. |
| output | Where emitted bundles go and how names such as [contenthash] are formed. |
| loaders | Per-file transforms, such as CSS, images, TypeScript, or JSX. |
| plugins | Build lifecycle hooks for HTML generation, constants, analysis, and framework integrations. |
| mode | A preset for development or production defaults. |
| chunks | Initial and lazy pieces of the graph; dynamic import() creates split points. |
| HMR | Hot Module Replacement swaps updated modules in development without a full page reload. |
module.exports = { mode: "production", entry: "./src/main.js", output: { filename: "[name].[contenthash].js", clean: true, }, module: { rules: [{ test: /\.css$/, use: ["style-loader", "css-loader"] }], }, plugins: [new HtmlWebpackPlugin()],};Source maps: debug generated code honestly
VLQA production bundle is not the same text you wrote. Source maps let DevTools connect a generated line and column back to the original file, line, and column. The generated file often points to the map with a final comment.
| Piece | What to remember |
|---|---|
sourceMappingURL | A comment at the end of generated code that points DevTools to a .map file. |
sources | The original files represented by the map, such as src/app.ts. |
mappings | A compact Base64 VLQ string that maps generated columns back to original lines and columns. |
devtool / sourcemap | Tool options that choose full maps, cheap maps, hidden maps, inline maps, or no public maps. |
console.log("compiled file");//# sourceMappingURL=app.js.mapThe compact part is mappings. It uses Base64 VLQ segments: relative numbers encoded with a continuation bit and a sign bit. The decoder below is small enough to read, and the test for this lesson proves it against a real source map emitted by the TypeScript compiler.
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; function decodeVlqSegment(segment) { const values = []; let value = 0; let shift = 0; for (const char of segment) { const digit = BASE64.indexOf(char); const continuation = digit & 32; value += (digit & 31) << shift; if (continuation) { shift += 5; continue; } const negative = value & 1; values.push((negative ? -1 : 1) * (value >> 1)); value = 0; shift = 0; } return values;} console.log(JSON.stringify(decodeVlqSegment("C")));console.log(JSON.stringify(decodeVlqSegment("D")));console.log(JSON.stringify(decodeVlqSegment("AAAA")));[-1]
Source-map mappings store relative numbers. The last bit is the sign: C decodes to positive 1, while D decodes to negative 1.
import ts from "typescript"; const result = ts.transpileModule("const answer: number = 40 + 2;", { compilerOptions: { sourceMap: true }, fileName: "answer.ts",});console.log(JSON.parse(result.sourceMapText).mappings);Production choices you will actually make
CONFIG + SORTMost teams do not write bundlers. They choose defaults, adjust plugins, decide which maps to publish, and watch output size. Minification might use Terser or esbuild. Filenames should include content hashes. Source maps should be public only when that is an intentional trade-off; many apps upload hidden maps to an error tracker instead.
import { defineConfig } from "vite";import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], build: { sourcemap: true, rollupOptions: { output: { entryFileNames: "assets/[name].[hash].js" }, }, },});| Choice | Good for | Watch out |
|---|---|---|
| Full public maps | Browser debugging and small internal apps | Original source may be visible to anyone. |
| Hidden maps | Error trackers that need stack traces | Upload step must match the deployed build. |
| Inline maps | Quick local experiments | Generated files become large and should not be production defaults. |
| No maps | Maximum source privacy | Production stack traces are harder to connect to original code. |
- Vite serves source files as native ESM while you develop.
`import("./charts.js")` becomes a lazy chunk.`//# sourceMappingURL=app.js.map`- A webpack rule sends
.cssfiles throughcss-loader. app.8f3a1c.jschanges name when content changes.- Hot Module Replacement updates the page after an edit.
Sort each card by whether it belongs to dev feedback, emitted bundle output, debugging, or config vocabulary.
Common misconceptions
- “Bundling is the same as transpiling.” They often run in the same build, but bundling changes file boundaries while transpiling changes syntax.
- “A bundler runs in the user's browser.” The bundler runs before deploy. The browser receives generated files.
- “Tree shaking removes every unused line.” It is conservative around side effects, dynamic access, and CommonJS.
- “Source maps are always safe to publish.” They can reveal original source, comments, and structure. Choose public or hidden maps deliberately.
- “Vite means no bundling.” Vite avoids bundling your whole app during most development requests, but production still emits optimized chunks.
| Word | Job |
|---|---|
| Bundling | Combines and rewrites module files for loading and optimization. |
| Transpiling | Changes syntax, such as TypeScript or modern JS, into another JavaScript form. |
| Polyfilling | Adds missing runtime APIs for older browsers. |
| Minifying | Renames, removes whitespace, and simplifies generated code for smaller downloads. |
Practice exercises
5 EXERCISESBefore bundling, this simple page would fetch each listed file. Type the printed number.
const requests = ["main.js", "format.js", "math.js", "style.css"];
console.log(requests.length);There are four files in the request list, so the program prints 4.
Run the emitted bundle mentally and type both console logs separated by a vertical bar.
(function(modules) {
const cache = {};
function require(id) {
if (cache[id]) return cache[id].exports;
const module = { exports: {} };
cache[id] = module;
modules[id](require, module, module.exports);
return module.exports;
}
require("/src/main.js");
})({
"/src/setup.js": function(require, module, exports) {
console.log("setup side effect");
},
"/src/math.js": function(require, module, exports) {
const version = "1.0.0";
function add(a, b) {
return a + b;
}
exports.add = add;
exports.version = version;
},
"/src/format.js": function(require, module, exports) {
const { version: version } = require("/src/math.js");
function label(value) {
return "v" + version + ": " + value;
}
exports.label = label;
},
"/src/main.js": function(require, module, exports) {
require("/src/setup.js");
const { add: add } = require("/src/math.js");
const { label: label } = require("/src/format.js");
console.log(label(add(2, 3)));
}
});The registry runs /src/setup.js, then /src/main.js, so the two logs are setup side effect | v1.0.0: 5.
Which unused exports does the mini bundler remove in the default entry?
The default bundle drops multiply from math and loud from format because no reachable import uses them.
Type the number printed by the tiny VLQ sign example.
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function decodeVlqSegment(segment) {
const digit = BASE64.indexOf(segment);
const negative = digit & 1;
return (negative ? -1 : 1) * (digit >> 1);
}
console.log(decodeVlqSegment("D"));D decodes to -1 because the low bit marks the number as negative and the shifted value is 1.
What package.json flag tells bundlers that unused import-only files can be dropped?
const packageJson = { "sideEffects": false };This flag tells bundlers they may drop files imported only for side effects when no exports are used. Use it only when true.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best defines a JavaScript bundler?
Choose an answer to see the explanation.
Question 2 of 7Why does this import need help in most browser pages?
Choose an answer to see the explanation.
Question 3 of 7What does the tiny emitted bundle print?
Read the code, then predict(function(modules) { const cache = {}; function require(id) { if (cache[id]) return cache[id].exports; const module = { exports: {} }; cache[id] = module; modules[id](require, module, module.exports); return module.exports; } require("/src/main.js"); })({ "/src/setup.js": function(require, module, exports) { console.log("setup side effect"); }, "/src/math.js": function(require, module, exports) { const version = "1.0.0"; function add(a, b) { return a + b; } exports.add = add; exports.version = version; }, "/src/format.js": function(require, module, exports) { const { version: version } = require("/src/math.js"); function label(value) { return "v" + version + ": " + value; } exports.label = label; }, "/src/main.js": function(require, module, exports) { require("/src/setup.js"); const { add: add } = require("/src/math.js"); const { label: label } = require("/src/format.js"); console.log(label(add(2, 3))); } });Choose an answer to see the explanation.
Question 4 of 7Why can ESM be tree-shaken more reliably than CommonJS?
Choose an answer to see the explanation.
Question 5 of 7As of this lesson's Sep 2026 facts, what changed in Vite 8?
Choose an answer to see the explanation.
Question 6 of 7Which webpack concept transforms individual file types?
Choose an answer to see the explanation.
Question 7 of 7What does a source map's
mappingsfield store?Choose an answer to see the explanation.
Key takeaways
- Bundlers turn a module graph into files browsers can load efficiently.
- Vite is a dev server and build tool; Vite 8 uses Rolldown by default as of this lesson.
- Tree shaking works best with ESM's static import and export structure.
- Dynamic
import()creates lazy chunks, usually named with content hashes. - Source maps map generated code back to original files; ship them intentionally.
Remember the one-liner.
A bundler follows imports before deploy, rewrites the graph into optimized chunks, and leaves source maps so humans can debug what machines generated.
Up next: Transpilers & polyfills, where you will separate syntax transforms from runtime API support.