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.
- 01Say what a bundler is forExplain how a bundler deals with many file downloads, npm package names, code changes, smaller code, and file-name codes.
- 02Follow a module graphFind each file's imports, put the files in order, write a small bundle, and run it.
- 03Make choices for a real buildUse tree shaking, dynamic imports, source maps, and the right tool, and know what each one costs.
What a bundler does
A real JavaScript app is almost never one file. It is split into many small files called modules. A module uses code from other modules with import. An app may import:
- your own components and helper files;
- CSS files and images;
- packages you installed with npm;
- code that should load only after a user clicks something.
A bundler is a tool that reads all those imports before you put the site online. Then it writes a few files that the browser can download quickly.
A bundler starts from one file, called the entry. It follows every import to find all the files the app needs. It changes files when needed, for example from TypeScript to JavaScript. Then it writes a few files for the browser. Each of those output files is called a chunk.
Many tools do this job, such as Vite, webpack, Rollup, esbuild, Turbopack, and Rspack. Most of them also do more: they run your app while you work on it, accept plugins, make the code smaller, and write source maps for debugging. This lesson explains each of these.
You order a few things from an online shop. You don't walk to ten shelves yourself. The warehouse reads your order, collects the items, and packs them into a few boxes. Each box gets a tracking number.
- In real life: The customer's order
- In JavaScript: The entry file, such as
/src/main.js - In real life: The packing list
- In JavaScript: The list of files found by following imports
- In real life: Items packed into a few boxes
- In JavaScript: Output files, called chunks, that load together
- In real life: A tracking number on each box
- In JavaScript: A code in each file name, such as
app.a1b2.js
This lesson builds on these lessons: why modules exist, import and export, module resolution, and npm and package.json. It also links to dynamic import and CommonJS vs ESM.
Why bundle
THE PROBLEMSBrowsers can load modules on their own. Modules loaded this way are called native ES modules. They work well. But a big app still runs into problems.
The first problem is a chain of downloads. The browser downloads main.js. Only then does it see what main.js imports, so it downloads those files next. Then it sees their imports, and so on. A step-by-step chain like this is called a waterfall.
const files = ["main.js", "cart.js", "price.js"];
console.log(files.join(" -> "));Line 1 lists three files. Line 2 joins them with arrows and prints main.js -> cart.js -> price.js. Read it as: main.js imports cart.js, and cart.js imports price.js.
Three files are not always three slow downloads. The browser can keep files it downloaded before; this is called caching. It can also download several files at the same time once it knows about them. So bundling is a trade-off. The goal is not simply the fewest files.
The second problem is package names. You import an npm package by its name, like lodash-es. A name with no ./ and no web address is called a bare specifier. The browser can't download a bare name on its own. It needs a web address.
The table below lists the main reasons teams bundle. Each row names a problem, then says what the bundler does about it.
| The problem | What the bundler does |
|---|---|
| Many small files | Without a bundler, the browser finds files one step at a time: main.js, then the files it imports, then their imports. The bundler joins them into a few files. |
| Package names | import { debounce } from "lodash-es" names a package, not a web address. The browser needs an import map, or a tool that swaps the name for a real file. |
| Code browsers can't read | TypeScript, JSX, CSS modules, Sass, and imported images must be changed first. The bundler changes them into files the browser understands. |
| Smaller, faster files | The bundler can make code smaller, leave out unused exports, move code that can wait into its own file, and add a content hash to each file name. |
A bundler can also change files before the browser sees them. These changes are called transforms:
- TypeScript becomes plain JavaScript.
- JSX, the HTML-like code used in React, becomes plain function calls.
- An
importof a CSS file becomes a real CSS file. - An
importof an image becomes the image's web address.
After that, the bundler can make the code smaller, remove code nobody uses, and move code that can wait into its own file. It can also add a short code to each file name, so the browser can keep files that didn't change for months. You'll see each of these later in this lesson.
A small site can skip the bundler and use an import map instead. An import map is a list that tells the browser which web address to use for each package name.
<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>In plain words, line by line:
- Line 1 starts the import map.
- Line 4 says: when code imports
lodash-es, download it from this web address. - Line 8 starts a module script.
- Line 9 imports
chunkby its package name. This works only because of the import map. - Line 10 splits
[1, 2, 3, 4]into groups of 2 and prints[[1, 2], [3, 4]].
A small site can send its modules to the browser as they are. That works when the site has a few modules, no JSX or TypeScript, no CSS imports, and a short import map. The module resolution lesson explains how the browser finds modules on its own. Most bigger apps sooner or later want transforms, file-name codes, or split files. Then a bundler helps.
The module graph: from the entry to every import
STEP THROUGHThe entry is the first file the bundler reads. Each import in it points to another file. Those files can import more files. Together, the files and their imports form a module graph: a map of which file imports which.
It is a graph, not a simple list, because two files can import the same file.
const imports = { cart: ["price"], checkout: ["price"] };
const files = new Set(["cart", "checkout", ...imports.cart, ...imports.checkout]);
console.log([...files].join(", "));Line 1 says that cart imports price, and checkout imports price too. Line 2 puts all the names in a Set. A Set keeps only one copy of each value, so price appears once. Line 3 prints cart, checkout, price.
A real bundler might put a shared file like price into its own chunk, which both pages load. Or it might copy a very small shared file into each chunk that needs it.
The bundler must also choose an order. A file can only run after the files it imports. So each file goes after the files it imports. Files can also import each other in a loop, called a cycle. Real bundlers handle cycles, but this lesson's example has none.
The replay below steps through that ordering. In the lesson's real bundler, a tool called acorn first reads the four files and finds their imports. The replay starts after that, so you can watch just the ordering.
Step through the ordering. acorn has already found each file's imports, so the replay starts from that map and ends with the order the bundle uses.
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
RUN ITHere is a real bundler that works, in a few dozen lines. It keeps four small files in memory:
main.js, the entry;setup.js, which only logs a message;math.js, which exportsversion,add, andmultiply;format.js, which exportslabelandloud.
The bundler does five jobs, in this order:
- It reads each file with
acorn. acorn is a library that turns JavaScript text into a tree of objects, one object for each piece of code. This tree is called an AST (abstract syntax tree). - It finds each file's imports and exports in that tree.
- It puts the files in order, so each file comes after the files it imports.
- It replaces each
importwith a call to a small function calledrequire. - It writes everything into one file that runs.
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);}In plain words, line by line:
- Line 1 loads the
parsefunction from acorn. - Line 4 reads one file.
sourceType: "module"tells acorn that the file usesimportandexport. - Lines 5 to 7 keep only the imports. Each path like
./math.jsbecomes a full path like/src/math.js. - Lines 11 and 12 skip a file that was already visited, and mark this one as visited.
- Line 13 visits every file that this file imports, first.
- Line 14 adds this file to the order, after the files it imports.
(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 bundle is plain JavaScript. It holds one function per file, runs /src/main.js, and prints the two logs on the right.
The output is old-style on purpose, so you can read it:
- An object holds one function for each file. The file's path is the key.
- A small
require(id)function runs a file's function. The file puts what it exports onmodule.exports. Thenrequiresaves that incache, so asking for the same file again doesn't run it twice. - Line 10 of the bundle calls
require("/src/main.js")to start the app.
Real bundlers write smaller, faster code. But this one shows the main idea with nothing hidden. It only knows its own four files. It doesn't handle cycles, export ... from lines, or npm packages.
Tree shaking: keep only the exports you use
USED EXPORTSTree shaking means leaving out exports that no code uses. Think of the module graph as a tree: shake it, and the parts nothing holds on to fall off.
It works well with ES modules. Their import and export lines use fixed names, so the bundler can read them without running any code. Tree shaking has four steps:
- Start at the entry.
- Mark each export that the entry imports and uses.
- Look inside those exports for more imports, and mark those too.
- Leave out every export that was never marked.
const exportsFromMath = ["add", "multiply"];
const used = new Set(["add"]);
console.log(exportsFromMath.filter((name) => used.has(name)).join(", "));Line 1 lists the two exports of math.js. Line 2 makes a set of used names that holds only add. Line 3 keeps the names that are in the set, and prints add.
math.js exports add and multiply, but only add is used. So only add goes into the bundle.This code is only a model of the idea, not a real bundler. A real bundler may also keep a file whose exports are all unused, if the file does other work when it loads. You'll see why below.
Tree shaking starts at the entry, marks the exports the code uses, and keeps only those.
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: 5setup.js stays, because importing it does work. Unused exports are left out. Use multiply too, and the bundle and its logs grow.
Some files do work as soon as they are imported. For example, they add styles to the page or add a name to a shared list. Work like this is called a side effect. A package can tell bundlers whether its files have side effects. It does this with the sideEffects field in its package.json file.
{ "name": "tiny-ui-kit", "sideEffects": false}const registered = [];
registered.push("checkout");
console.log(registered.join(", "));Line 1 makes an empty list. Line 2 adds checkout to it. Line 3 prints checkout. Now imagine lines 1 and 2 in their own file. That file exports nothing. But removing it would leave the list empty. So the sideEffects field is about whole files, while tree shaking looks at single exports.
"sideEffects": false is a promise from the package. It says: if no code uses a file's exports, it is safe to leave that file out, even if something imports it. If the promise is wrong, parts of the app can disappear, such as CSS or code that registers something.
{
"name": "tiny-ui-kit",
"sideEffects": ["**/*.css", "./src/register.js"]
}This version lists the files that do have side effects: every CSS file, and register.js. The bundler keeps those, and can still leave out other unused files. The package.json file is a settings file for tools. It doesn't run in the browser.
Before you promise "sideEffects": false, check a production build. A development build may keep code that the production build removes.
A /*#__PURE__*/ comment is a smaller promise about one function call. It is read by the minifier, the tool that makes code smaller. It says: if nothing uses this call's result, you can remove the call.
const button = /*#__PURE__*/ createButton({ variant: "ghost" });export const used = button.label;export const unused = /*#__PURE__*/ createChartTheme();Line 2 uses button, so the call on line 1 stays. Line 3 exports unused. If no other file imports unused, the minifier may remove the call on line 3.
Older Node.js code uses CommonJS: require() and module.exports. CommonJS is harder to tree-shake. A require() path can be built while the code runs. Code can also change module.exports at any time. Tools still handle common cases. But ES modules have a fixed shape, so tools can trust them more.
Dynamic import makes separate chunks
LOAD LATERAn import line at the top of a file is a static import. The file it names always loads with the app.
A dynamic import, written import("./file.js"), loads a file later, while the code runs. It returns a promise: an object that receives the file's exports once the file has loaded.
A bundler sees import() as a place where it can split the app. It can put that file, and the files it imports, into a separate chunk. Then heavy code waits until the user needs it. The final split can still differ: a very small file, or a file other code also uses, may end up in another chunk.
const entry = ["main", "cart"];
const lazy = ["profile"];
console.log(entry.join(" + ") + " | later: " + lazy.join(", "));Line 1 lists the code that loads now: main and cart. Line 2 lists the code for later: profile. Line 3 prints main + cart | later: profile.
main and cart load with the page. profile sits in its own file and loads later, when the code calls import().This is only a plan, not the real files a build makes. To see the real chunks, look at the files in your production build.
const button = document.querySelector("#show-chart"); button.addEventListener("click", async () => { const { renderChart } = await import("./charts.js"); renderChart();});In plain words, line by line:
- Line 1 finds the button on the page.
- Line 3 runs the code inside only when someone clicks the button.
- Line 4 loads
charts.js.awaitpauses this function until the file has arrived. - Line 5 calls
renderChart()from that file, which printschart chunk loaded.
In a production build, chunk file names usually include a content hash. A content hash is a short code made from the file's contents, like a1 in cart.a1.js. If the chart code changes, its chunk gets a new name. Chunks that didn't change keep their old names.
Loading a chunk can fail. For example, a new version of the site may have removed an old chunk. So catch the error from import(), and offer the user a reload or a retry. The dynamic import lesson covers import() itself in more detail.
const previous = { cart: "cart.a1.js", profile: "profile.b2.js" };
const next = { ...previous, profile: "profile.c3.js" };
console.log(next.cart === previous.cart, next.profile === previous.profile);Line 1 holds the old file names. Line 2 copies them and gives profile a new name. Line 3 compares the names. The cart name is the same, and the profile name is different, so it prints true false.
In real builds, a file that imports the profile file can get a new name too, because it now holds the profile file's new name. Send hashed files with a long cache time, so browsers keep them. But always send a fresh copy of the HTML page, so browsers find the new file names.
Vite, esbuild, Rollup, Rolldown, webpack, and Parcel
THE TOOLSVite is two things: a development server and a build tool. A development server is a program that runs your app on your own computer while you work on it. While you work, Vite:
- sends your files to the browser as native ES modules, without bundling the whole app;
- changes each file, for example from TypeScript to JavaScript, only when the browser asks for it;
- bundles your npm packages once, ahead of time, so a package made of hundreds of small files doesn't slow down every page load. This is called pre-bundling.
For the final build, older Vite versions used Rollup to bundle, and esbuild for many fast transforms.
const mode = "production";
const tasks = mode === "production" ? ["bundle", "minify", "hash"] : ["transform", "reload"];
console.log(tasks.join(" -> "));Line 1 sets the mode to production. Line 2 picks the list of jobs for that mode. Line 3 prints bundle -> minify -> hash. Change line 1 to "development", and it prints transform -> reload. This is a simple list, not how Vite really works inside.
In development, hot module replacement (HMR) swaps in a file you just edited, without reloading the whole page. In production, the build makes the files you put online.
Vite 7 was announced on June 24, 2025. It offered rolldown-vite as an optional swap, and said Rolldown would become the default later. Vite 8 was announced on March 12, 2026. It uses Rolldown as the default bundler for both development and production. If you read this much later, check the Vite release notes for the newest default.
The table below compares the tools. Read each row as: the tool, its main job, and a simple way to think about it.
| Tool | Main job | How to think about it |
|---|---|---|
| Vite | A development server plus a build tool. | While you work, it sends files as native ES modules and changes each one only when asked. It bundles npm packages ahead of time. Vite 8 uses Rolldown by default. |
| esbuild | A very fast tool that reads, changes, and bundles code. | It is written in Go. Older Vite versions used it to pre-bundle packages and change files during development, because it is so fast. |
| Rollup | A bundler made for ES modules first. | It writes very clean output for libraries and apps. It has a well-tested plugin system and good tree shaking. |
| Rolldown | The bundler inside Vite 8, written in Rust. | It aims to work with Rollup's plugins. It is a separate project, not the same as Rollup. |
| webpack | A bundler for apps that you can set up in great detail. | It is built around entries, output, loaders, plugins, modes, chunks, and HMR. Many large apps use it. |
| Parcel | A build tool that needs very little setup. | It starts from an HTML file and follows everything that file uses. For common apps, it builds production files with no config file. |
In short:
- esbuild is written in the Go language, and is known for being very fast.
- Rollup was made for ES modules first. It has a well-tested plugin system and writes very clean output for libraries.
- Vite combines tools like these. You get fast feedback while you work, and small files for production, without writing a big config file.
- Rolldown is Vite 8's bundler, written in the Rust language. It is a separate project, not a new name for Rollup.
- Parcel is another build tool. It starts from an HTML file and needs very little setup.
Pick a tool by what your project needs and which plugins you use, not by speed alone.
webpack words, Next.js, Turbopack, and Rspack
WORDSwebpack is an older, very popular bundler. Its words are still used everywhere. Even when a framework hides the config, you'll hear about entries, loaders, plugins, chunks, and HMR.
Next.js, the framework that builds this site, uses a bundler underneath. Turbopack and Rspack are newer bundlers written in Rust. They keep webpack's familiar ideas and aim to make builds faster.
Each row of the table below is one webpack word and what it means.
| Word | What it means |
|---|---|
| entry | The file, or files, webpack starts from to build the module graph. |
| output | Where webpack writes the finished files, and how it names them, for example with [contenthash]. |
| loaders | Tools that change one kind of file, such as CSS, images, TypeScript, or JSX. |
| plugins | Extra steps added to the build, such as writing the HTML page, setting constants, or measuring file sizes. |
| mode | A set of default settings, either for development or for production. |
| chunks | The pieces of the finished app: some load at the start, some load later. A dynamic import() starts a new piece. |
| HMR | Hot Module Replacement. While you work, it swaps in an edited file without reloading the whole page. |
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()],};In plain words, line by line:
- Line 2 picks production mode.
- Line 3 names the entry file.
- Line 5 adds a content hash to each output file name.
- Line 9 sends every
.cssfile through two loaders. - Line 11 adds a plugin that writes the HTML page.
This config runs in Node.js during the build, not in the browser, so it can't run in the editor here. To use the plugin on line 11, a real project must first install and import HtmlWebpackPlugin.
Source maps: find your own code in the built files
DEBUGGINGThe code a bundler writes is not the code you wrote. Names may be shorter, and many files may be joined into one. A source map is a file that links the two. For a place in the built code, it says which file, line, and column it came from. DevTools, the browser's tools for developers, uses it to show you your own code.
const original = { file: "cart.ts", line: 3 };
const generated = { file: "app.js", line: 1 };
console.log(generated.file + ":" + generated.line + " -> " + original.file + ":" + original.line);Line 1 describes a place in the code you wrote: line 3 of cart.ts. Line 2 describes the matching place in the built code: line 1 of app.js. Line 3 prints app.js:1 -> cart.ts:3.
app.js. The source map tells DevTools that line 1 of app.js came from line 3 of cart.ts.Real maps also store columns. And not every place in the built code has a match. DevTools uses the map only to show where code came from. The browser still runs the built code, not your original files.
The table below lists the parts of a source map you'll meet. Each row names one part and says what it does.
| Part | What it does |
|---|---|
sourceMappingURL | A comment at the end of the built file. It tells DevTools where the .map file is. |
sources | The list of your original files that the map covers, such as src/app.ts. |
mappings | One short string, in Base64 VLQ, that links each place in the built code to a line and column in your files. |
devtool / sourcemap | The tool setting that picks the kind of map: full, quick, hidden, inside the file, or none. |
The built file usually points to its map with a comment on its last line.
console.log("compiled file");//# sourceMappingURL=app.js.mapLine 1 is the built code. Line 2 is the comment that tells DevTools to load app.js.map.
The part of the map that does the real work is mappings. It is one long string, made of short pieces called segments. Each segment stores a few small whole numbers, written in a format called Base64 VLQ. It is a way to write numbers as letters. A bit, used below, is a single 0 or 1 inside a number.
- Each letter stands for a number from 0 to 63.
Ais 0,Bis 1,Cis 2,Dis 3. - Each number is a difference from the one before, not a full position. That keeps the numbers small.
- One bit of each letter, the bit worth 32, says "this number goes on in the next letter".
- The lowest bit of each number says whether it is positive (0) or negative (1).
The decoder below is short enough to read. This lesson's test also checks it against a real source map made 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]
Each letter stands for a small number. The lowest bit is the sign: C decodes to 1, and D decodes to -1. AAAA is four zeros.
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);Line 3 turns one line of TypeScript into JavaScript. Line 4 asks for a source map too. Line 7 prints the map's mappings string. This code needs the typescript package, so it runs in Node.js, not in the editor here.
Choices you'll make for a real build
CONFIG + SORTMost teams never write a bundler. Instead they:
- pick the tool's defaults;
- add or change plugins;
- decide which source maps to publish;
- watch how big the output files are.
A minifier makes code smaller. It removes spaces and shortens names, without changing what the code does. Terser and esbuild are common minifiers. By default, Vite 8 uses its own tools, Rolldown and Oxc.
File names should include content hashes. Make source maps public only if you have decided to. Many apps instead upload hidden maps to an error tracker: a service that collects errors from users' browsers.
const cart = [2, 3];
console.log(cart.reduce((total, price) => total + price, 0));Line 1 holds two prices. Line 2 adds them with reduce, which walks the list and keeps a running total. It prints 5. A minifier may shorten names and remove spaces, but the result must stay 5. It doesn't remove the adding.
To measure what minifying saves, compare the size of the output files after compression. Don't count the lines you wrote.
import { defineConfig } from "vite";import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], build: { sourcemap: "hidden", rolldownOptions: { output: { entryFileNames: "assets/[name].[hash].js" }, }, },});In plain words, line by line:
- Line 5 adds the React plugin.
- Line 7 writes source maps, but leaves out the comment that points to them.
- Line 9 adds a content hash to each output file name.
This file runs in the build tool, not in the browser, so it can't run in the editor here. Vite 8 reads output settings from rolldownOptions. Upload hidden maps to your error tracker if you need error reports that point to your own code.
The table below compares four ways to ship source maps. Read each row as: the choice, when it helps, and what to watch for.
| Choice | Good for | Watch out for |
|---|---|---|
| Public maps | Debugging in the browser, and small apps used only inside your team. | Anyone can read your original code. |
| Hidden maps | Error trackers that turn errors back into your own code. | You must upload the maps from the same build you put online. |
| Inline maps | Quick tests on your own computer. | The map sits inside the file, so files get large. Don't use them for production. |
| No maps | Keeping your original code private. | Errors from users are harder to match to your own code. |
- Vite sends your files as native ES modules while you work.
`import("./charts.js")` becomes a chunk that loads later.`//# sourceMappingURL=app.js.map`- A webpack rule sends
.cssfiles throughcss-loader. app.8f3a1c.jsgets a new name when its contents change.- Hot Module Replacement updates the page after an edit.
Sort each card: is it about the development server, the output files, debugging, or a config word?
Common misconceptions
- “Bundling is the same as transpiling.” They often happen in the same build. But bundling changes how code is split into files. Transpiling changes how the code is written, for example from TypeScript to JavaScript.
- “A bundler runs in the user's browser.” The bundler runs before the site goes online. The browser only gets the files the bundler wrote.
- “Tree shaking removes every unused line.” Tree shaking is careful. It keeps code that might have side effects, code reached in ways it can't predict, and most CommonJS code.
- “Source maps are always safe to publish.” They can show anyone your original code, comments, and file names. Decide on purpose whether maps are public or hidden.
- “Vite means no bundling.” While you work, Vite mostly avoids bundling the whole app. But the production build still writes bundled chunks.
These four words often show up together. The table below says what job each one does.
| Word | Its job |
|---|---|
| Bundling | Joins and rewrites module files, so the browser can load them quickly. |
| Transpiling | Changes how code is written, for example from TypeScript or new JavaScript into JavaScript that more browsers understand. |
| Polyfilling | Adds built-in features, such as a missing function, that older browsers lack. |
| Minifying | Makes the built code smaller: shorter names, no extra spaces, simpler code. |
Practice exercises
5 EXERCISESWithout a bundler, this simple page would download each file in the list. Type the number the program prints.
const requests = ["main.js", "format.js", "math.js", "style.css"];
console.log(requests.length);There are four files in the list, so the program prints 4.
Run the bundle in your head. Type both console logs, with a vertical bar (|) between them.
(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)));
}
});main.js runs /src/setup.js first, then logs its own result. So the two logs are setup side effect | v1.0.0: 5.
With the default entry, which unused exports does the tiny bundler leave out?
The bundle leaves out multiply from math.js and loud from format.js, because no code that runs uses them.
This small program decodes the VLQ letter D. Type the number it prints.
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. Its lowest bit marks the number as negative, and the rest of its bits make 1.
Which package.json field and value tell bundlers this: a file that is imported, but whose exports nobody uses, can be left out?
const packageJson = { "sideEffects": false };This field tells bundlers that if no code uses a file's exports, they may leave the file out, even if something imports it. Only add it when that is true for every file.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best describes a JavaScript bundler?
Choose an answer to see the explanation.
Question 2 of 7Why does
import { chunk } from "lodash-es"need help in most web pages?Choose an answer to see the explanation.
Question 3 of 7What does this tiny 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 is tree shaking more reliable for ES modules than for CommonJS?
Choose an answer to see the explanation.
Question 5 of 7As of this lesson (September 2026), what changed in Vite 8?
Choose an answer to see the explanation.
Question 6 of 7Which webpack word means a tool that changes one kind of file?
Choose an answer to see the explanation.
Question 7 of 7What does the
mappingsfield of a source map hold?Choose an answer to see the explanation.
Key takeaways
- A bundler follows imports from the entry, and turns your many files into a few files the browser can load quickly.
- Vite is a development server and a build tool. As of this lesson, Vite 8 uses Rolldown by default.
- Tree shaking leaves out unused exports. It works best with ES modules, because their imports and exports use fixed names.
- A dynamic
import()lets the bundler move code into a chunk that loads later. Check the build to see the real chunks. - Source maps lead from the built code back to your own files. Decide on purpose whether to publish them.
Remember the one-liner.
A bundler follows your imports before the site goes online, writes a few files for the browser, and can write source maps so you can debug them.
Up next: Transpilers & polyfills. There you'll learn the difference between changing how code is written and adding features that older browsers lack.