Skip to content
Software Atlas

Build Tools & Bundlers

What actually happens between npm run build and the files your users download: modules, the dev server, transpiling, tree shaking, chunks and hashes, minification, source maps and env variables. Every output shown is real Vite 8 output, including this site's own build.

⏱ ~45 min read15 sections11 interactive widgetsJS · TS · JSX
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

You type npm run dev and a page appears. You type npm run build, a dist/ folder appears, and somebody deploys it. Between your .jsx files and what the browser runs, a tool rewrote everything. This topic opens that box.

In one sentenceA Bundler reads your app starting from one file, follows every import, translates what browsers can't run, throws away what you don't use, and packs the rest into a few small files with fingerprinted names.
What a build does, in seven steps
Many source files go in; a few optimized files come out. The lit step is the one working right now.
Your project
main.jsx
App.jsx
Cart.tsx
Chart.jsx
styles.css
react (npm)
date-fns (npm)
The bundler
  1. 1Resolve
  2. 2Transform
  3. 3Tree-shake
  4. 4Split
  5. 5Bundle
  6. 6Minify
  7. 7Hash & write
dist/ (what you deploy)
index.html
assets/index-DbfMV5Es.js
assets/Chart-B4NChJ1F.js
assets/index-C3niFtPQ.css

Seven source files, two of them npm packages. Press Next step or let it play.

1

Two modes, two jobs

npm run dev serves your files almost as written, so changes show up instantly. npm run build does the full packing job once, for real users on real networks (§05).
2

It reads, it doesn't run

A bundler works out what your code needs by reading it. That's why the import syntax matters so much (§03) and why some code can't be removed (§07).
3

The output is public

Everything in the bundle, including every "environment variable" you put in it, is downloaded by every visitor (§10).

Everything here is shown with real output from Vite 8, the build tool this site itself uses. In §11 we take apart this site's own build: a real problem it had, the fix that didn't work, and the one that did.

Foundations§02

Why bundlers exist

Modern browsers understand import and export natively. So why not ship the files exactly as you wrote them? Let's try, and watch three things break.

Problem 1: the browser doesn't know what "react" means

Here's a page that loads one module, written the way every React tutorial writes it:

index.html
<script type="module">
  import { useState } from "react";
</script>

We opened this in Chromium. It failed before running a single line:

Failed to resolve module specifier "react". Relative references
must start with either "/", "./", or "../".

"react" is a Bare specifier: a package name, not a path. Node knows to look in node_modules; a browser has no idea where that folder is, or whether it exists. Something has to rewrite "react" into a real URL, and finding the file behind a name is called module resolution.

Problem 2: the browser can't run JSX or TypeScript

<button onClick={save}> inside a .js file is a syntax error to a browser, and so is const n: number = 1. JSX and TypeScript exist only for you. Before a browser sees them they must be translated into plain JavaScript (§06).

Problem 3: the import waterfall

This is the one that hurts real users. The browser can't know what App.js imports until it has downloaded and read App.js. Only then can it request the next level of files, read those, find their imports, and so on. Each level costs at least one round trip to the server, one after another. That chain is a Request waterfall.

The import waterfall
A browser only learns about a file's imports after it has downloaded that file. Simplified model: each request costs one round trip plus 5 ms of download.
Network timeline525 ms
main.js
· App.js
· api.js
·· Header.js
·· ProductList.js
·· format.js
··· Button.js
··· Price.js
··· ProductCard.js
···· Icon.js
···· currency.js
requests
11
round trips in a row
5
time until all code is here
525 ms

The browser can't ask for Button.js until it has read ProductList.js, which it couldn't ask for until it had read App.js… Five levels deep means five round trips in a row, however fast the server is.

Worked example. A small app five imports deep, on a 100 ms connection: at least 5 × 100 = 500 ms just discovering files, before any of your code runs. A real app is often 10 to 20 levels deep, with hundreds of files.

And "hundreds" is an understatement. When this site runs in development mode, opening one topic page makes the browser send 2,212 requests for modules, styles and assets (we counted them by loading the page in a script-driven browser). That's fine from your own laptop, where each request takes a few milliseconds (the whole page settled in about 3.9 s). Over a real network, it would be painfully slow.

What a bundler does about all three

A Bundler runs before deployment, on your machine or a build server. It reads the whole import tree at once (no network involved), resolves every package name, translates JSX and TypeScript, and writes a few files where the browser needs no discovery: everything for the first screen arrives in one or two requests.

ProblemWhat the bundler does
"react" isn't a URLFinds the file in node_modules and includes it
JSX and TypeScriptTranslates them into plain JavaScript
Waterfall of hundreds of filesPacks them into a few files, discovered in one go
Unused code shipped anywayDrops it (§07)
Big filesShrinks them (§09) and splits off what can wait (§08)
Foundations§03

Modules: ESM vs CommonJS

A module is a file with its own private scope: variables inside it stay inside unless the file exports them. JavaScript ended up with two different ways to do this, and you'll meet both in every real project.

Two systems, side by side

math.cjs
function add(a, b) { return a + b; }

module.exports = { add };          // ← what this file shares

// somewhere else:
const { add } = require("./math.cjs");   // ← an ordinary function call

CommonJS came first: Node invented it in 2009, before JavaScript had modules at all. ES modules (ESM) were added to the language itself in 2015 and now work in every browser and in Node. New code should be ESM. You'll still meet CommonJS inside node_modules: even React's npm package is CommonJS today (Vite has to wrap it, as you'll see in §05).

The difference that matters: when imports are known

require() is just a function. It can sit inside an if, inside a loop, or take a path built from variables. You only find out what a file needs by running it.

An import declaration is stricter on purpose. It must be at the top level of the file (not inside a function or if), and the path must be a plain string. So a tool can find every import, and exactly which names each one uses, by reading the file. That property has a name: imports are static.

What can a tool know without running your code?
A bundler reads your files; it doesn't run them. Switch formats and see what it can be sure of.
// main.js (ES module)
import { slugify } from "./utils.js";

console.log(slugify("Hi there"));
What the bundler knows
  • Which files does main.js need?
    ✓ utils.js — written right there in the code
  • Which exports does it use?
    ✓ only slugify
  • Can unused exports be dropped?
    ✓ yes: nothing else can reach them
  • When are imports known?
    ✓ before any code runs

import and export must sit at the top level of the file, and the path must be a plain string. That rule is what makes the whole import graph readable without running anything.

That's why the rest of this topic keeps coming back to ESM: tree shaking (§07) and smart chunking (§08) only work fully on code a tool can analyse without running it.

How Node decides which one a file is

FileTreated as
x.mjsalways ESM
x.cjsalways CommonJS
x.jswhatever the nearest package.json says: "type": "module" → ESM, otherwise CommonJS

This site's package.json has "type": "module", so every .js file in it is ESM. (Node 22 will also guess ESM from the syntax when nothing says otherwise. Don't rely on it: set "type".)

The errors you'll actually see

Mixing the two systems produces a few famous messages. We ran each one in Node 22.22:

You wrote…Node says
import in a file Node treats as CommonJSSyntaxError: Cannot use import statement outside a module
require() in an ESM fileReferenceError: require is not defined in ES module scope, you can use import instead
import { greet } from "./old.cjs" where old.cjs did module.exports = { greet: … }SyntaxError: Named export 'greet' not found. The requested module './old.cjs' is a CommonJS module, which may not support all module.exports as named exports.
require("./new.mjs") where new.mjs uses top-level await (await outside any function)Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await

The third one is surprising: the export clearly exists. Node can only guess a CommonJS file's export names by scanning its text for patterns like exports.greet = …. An object literal with arrow functions inside isn't one of them. The fix Node suggests works: import pkg from "./old.cjs" and then pkg.greet().

Bundlers smooth most of this over: inside a Vite project you can usually import a CommonJS package and it just works, because the bundler wraps the CommonJS code in a small function that fakes module.exports for it. The price: that wrapped code can't be tree-shaken well. §07 shows it.

Foundations§04

Inside a build: one tiny app, step by step

Every bundler, from webpack to Rolldown, does roughly the same job in roughly the same order. Here it is on an app small enough to follow completely: a main.js that uses one function from a small npm package called fancy-utils (an index.js that re-exports three little files), plus a CSS file. Step through with Next (or →).

One tiny build, step by step
main.js, a small npm package (an index.js that re-exports three little files) and a CSS file go in. The last step shows the real Vite 8 output.
Code
1// src/main.js
2import { slugify } from "fancy-utils";
3import "./styles.css";
4document.title = slugify("Hello World");
5
6// node_modules/fancy-utils/package.json
7{ "name": "fancy-utils", "main": "index.js",
8 "type": "module", "sideEffects": false }
9
10// node_modules/fancy-utils/index.js
11export { formatDate } from "./date.js";
12export { slugify } from "./string.js";
13export { bigChart } from "./chart.js";
14
15// node_modules/fancy-utils/string.js
16export const slugify = (s) =>
17 s.toLowerCase().replace(/\W+/g, "-");
18// (date.js and chart.js: two more small files like this)
Module graphfiles found so far
src/main.js entry
Bundler's noteswhat it decided
empty
dist/output files
nothing written yet
Every build starts at an entry point: the file where the app starts. In Vite, index.html is the real entry, and its <script type="module" src="/src/main.js"> points here. The bundler reads src/main.js and parses it into a tree it can inspect (an AST, or abstract syntax tree).
1 / 9

The three phases

The steps fall into three groups. Knowing which group a problem lives in tells you where to look.

PhaseStepsQuestion it answers
Build the graphfind the Entry point, resolve, load (parse each file into an AST), transformWhich files exist, and what do they import?
Decidelink, tree-shake, split into chunksWhich code is needed, and in which output file?
Writeconcatenate, minify, hashWhat exactly goes into each file?

The Module graph is the bundler's central data structure: one node per file, one arrow per import. Every later decision (what's unused, where to split, which hash changes) is a question about that graph.

The module graph for the stepper's app
src/main.js entry
→ fancy-utils/index.js
→ → string.js (slugify)
→ src/styles.css
→ → date.js (formatDate)
→ → chart.js (bigChart)
Arrows are imports. Green survived the build; red was tree-shaken away. index.js vanishes too: it only re-exported other files' names.

Why the output has no module wrappers

Look at the last steps again: the output is just slugify's code followed by main.js's code, in one flat file. There's no require() function or registry of modules at run time. This is called Scope hoisting: with ES modules every import is already wired up at build time, so the bundler can paste the code side by side and rename anything that clashes.

Older bundlers (and today's bundlers when they meet CommonJS) instead wrap each module in a function and ship a tiny module loader to call them in order. That works, but it's bigger and slower to start, and it hides which exports are used. You'll see such a wrapper in the §07 lab.

Practitioner§05

Vite: dev server vs production build

Older tools like webpack bundled the whole app before you could see anything, and re-bundled on every save. On big apps that meant waiting tens of seconds to start. Vite's idea: in development, don't bundle your code at all.

npm run dev vs npm run build
Same source code, two very different jobs. Then save a change to Button.jsx and see what each one redoes.
What the browser asks the dev server for
  1. GET /
    index.html, with a small /@vite/client script added (it listens for changes)
  2. GET /src/main.jsx
    JSX → JS on request; "react" rewritten to /node_modules/.vite/deps/react.js
  3. GET /node_modules/.vite/deps/react.js
    React, pre-bundled once into a single file when the server started
  4. GET /src/App.jsx
    transformed when the browser asks for it, not before
  5. GET /src/Cart.tsx
    types stripped on request
  6. GET /src/Button.jsx
    …and so on, one request per source file
requests, first load of this site
2,212
work before the server is ready
little: pre-bundle deps once
after saving Button.jsx
—

The dev server does little work up front (it pre-bundles your npm packages once). The browser loads the code as native ES modules, and each file is transformed the moment it's requested. Lots of requests, but they come from your own machine, so each one takes only a few milliseconds.

Numbers measured on this site on the authoring machine (headless Chromium 153, Vite 8.3.5).

In development: the browser does the module loading

npm run dev starts a Dev server. It sends index.html to the browser, and the browser starts requesting modules itself, using native ES module loading (§03). Each request is handled on the spot: the dev server reads that one file, transforms it, and sends it back. Files you never open are never transformed.

Here's what the dev server actually sent our browser for this site's src/main.jsx (only the import part, re-wrapped to fit):

What's on disk
src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
→ What the browser receives in dev
GET /src/main.jsx
const StrictMode = __vite__cjsImport0_react["StrictMode"];
import __vite__cjsImport0_react from
  "/node_modules/.vite/deps/react.js?v=e7a88d54";
import __vite__cjsImport1_reactDom_client from
  "/node_modules/.vite/deps/react-dom_client.js?v=e7a88d54";
import App from "/src/App.jsx?t=1793389393844";
import "/src/index.css?t=1793389240599";

Three things happened in that file:

  1. Bare names became URLs. "react" became /node_modules/.vite/deps/react.js, which fixes Problem 1 from §02.
  2. CommonJS got wrapped. React's npm package is CommonJS, so Vite imports it as one object (__vite__cjsImport0_react) and pulls StrictMode off it (the first line; imports are hoisted, so it still runs after them).
  3. Query strings were added. ?v=… is a version stamp of the pre-bundled packages. ?t=… is a timestamp that appears on files you've edited since the server started (we had touched App.jsx and index.css). Change a file and its URL changes, so the browser can't use a stale copy.

Pre-bundling: the exception for npm packages

Your own files are served one by one, but npm packages are not. A package like react-dom is spread over many internal files, and some packages are CommonJS, which browsers can't import at all. So when the dev server starts, Vite does dependency pre-bundling: it bundles each dependency once into a single ES module file under node_modules/.vite/deps/. Our page made 14 requests to that folder, out of 2,212 in total.

Pre-bundling runs again only when your dependencies change (Vite watches your Lockfile). If it ever gets stuck on an old version, vite --force rebuilds it.

HMR: changing code without losing state

When you save a file, the dev server sends a message over a WebSocket to the small /@vite/client script it injected into the page. The page then fetches just the new version of that module and swaps it in. With React, the component re-renders with its state kept: the dialog stays open, the half-typed form stays typed. That's hot module replacement (HMR).

In production: bundle everything, once

npm run build is a different program path. Rolldown reads the whole Module graph, then tree-shakes, splits, minifies and hashes (§07 to §09). On this site that's 1,255 modules turned into one JS file and one CSS file in 8.75 seconds.

dev start
pre-bundle deps
dev load
html
~2,200 module requests, each transformed on demand
build
transform all 1,255 modules
tree-shake · chunk · minify
write
Illustrative proportions, not to scale. Dev shifts the work to "when you open a page"; build does it all up front.
Practitioner§06

Transpiling TypeScript and JSX

To Transpile is to translate source code into other source code: here, TypeScript and JSX into plain JavaScript that a browser can run. In Vite 8, Oxc does this for each file.

From .tsx to something a browser can run
Real output from Vite 8 with @vitejs/plugin-react for the same component.
import { useState } from "react";

type Props = { label: string; start?: number };

export function Counter({ label, start = 0 }: Props) {
  const [count, setCount] = useState<number>(start);
  return <button onClick={() => setCount(count + 1)}>{label}: {count}</button>;
}
What changed
  • What you write: a type alias (type Props), a type annotation (: Props), a type argument (<number>) and JSX. Browsers understand none of these four.

TypeScript: deleted, not checked

Switch the widget to Dev server output: type Props, : Props and <number> are simply gone. Vite transforms each file on its own and removes the type syntax, which is fast because it never has to understand how types relate across files. The consequence surprises almost everyone the first time:

src/main.ts
const total: number = "not a number"; // ← a type error

We built this with vite build. No error, no warning. The output:

dist/assets/index-B3-vw0LL.js (unminified)
var total = "not a number";

To type check, you run the real TypeScript compiler separately. Your editor does it as you type; in CI, add it as its own step so a type error fails the pipeline:

package.json
{
  "scripts": {
    "build": "tsc --noEmit && vite build",
    "typecheck": "tsc --noEmit"
  }
}

JSX: function calls in disguise

<button onClick={…}>{label}</button> becomes a call: jsxs("button", { onClick: …, children: [label, ": ", count] }). The function comes from react/jsx-runtime, which the transform imports for you. That's why modern React files don't need import React from "react" any more.

The dev and production outputs differ slightly. Dev calls jsxDEV(…) and passes your file name and line number, so React's warnings can point at your source. Production calls jsx/jsxs without them: smaller and faster. This is part of what development vs production builds means.

Old browsers: syntax yes, missing features no

Vite also rewrites JavaScript syntax that your target browsers can't parse. This is Syntax lowering, and build.target controls it. Vite 8's default, "baseline-widely-available", means Chrome/Edge 111+, Firefox 114+ and Safari 16.4+. We built the same function with the default and with an older target:

target: 'baseline-widely-available' (the default)
function label(user) {
  return user?.name ?? "guest";
}
console.log([1, 2, 3].at(-1));
→ target: 'es2019' (only if you must support older browsers; bigger output)
function label(user) {
  var _user$name;
  return (_user$name = user === null || user === void 0
    ? void 0 : user.name) !== null && _user$name !== void 0
    ? _user$name : "guest";
}
console.log([1, 2, 3].at(-1));   // ← unchanged!

?. and ?? were rewritten into older syntax. But [1, 2, 3].at(-1) was left exactly as it was. .at() isn't syntax; it's a method that old browsers simply don't have. Supplying missing methods takes a Polyfill, and Vite doesn't add those for you. (The @vitejs/plugin-legacy plugin can, if you truly need very old browsers.)

Practitioner§07

Tree shaking and side effects

In one sentenceTree shaking keeps only the code reachable from what you actually import. It works on ES modules, and it stops at any code that might do something just by being loaded.

The name comes from shaking a tree so the dead leaves fall off. The bundler starts from your Entry point, marks every export that's really used, and everything unmarked is dropped. In the moving analogy: you only pack what someone will actually use in the new flat.

The catch: code that runs when a file loads

Not everything in a file is an export. Code written directly in a file, outside any function, is Top-level code: it runs the moment the file is loaded. If that code changes something outside itself (logs, sets a global, registers a plugin, injects CSS), it has a side effect.

The bundler can't know whether you wanted that side effect. Maybe import "./polyfills.js" is the whole point of the file! So by default it keeps any top-level code that might have an effect, even if you import nothing from that file.

Try it. The library below has three files; chart.js logs a line when it loads. Turn imports on and off, flip the package's "sideEffects" flag, and switch the library to CommonJS:

Tree-shaking lab: which code survives?
Pick what main.js imports from a small library. The output on the right is Rolldown's real output for that exact combination (28 builds, captured).
// main.js import { slugify } from "fancy-utils";
Library source3 files
fancy-utils/date.js
export function formatDate(d) {
return d.toISOString().slice(0, 10);
}
fancy-utils/string.js
export function slugify(s) {
return s.toLowerCase().replace(/\W+/g, "-");
}
fancy-utils/chart.js
console.log("registering 40 chart types");
export function bigChart(data) {
return data.map((v) => "#".repeat(v)).join("\n");
}
Green = in the output. Red and struck through = removed.
Real output (unminified)129 bytes minified
// fancy-utils/string.js
function slugify(s) {
  return s.toLowerCase().replace(/\W+/g, "-");
}
// fancy-utils/chart.js
console.log("registering 40 chart types");
// main.js
console.log(slugify("Hello World"));

You don't use bigChart, so it's gone, but chart.js's console.log line survived. Without a promise from the package, the bundler must assume loading chart.js matters (it might register something or patch a global), so it keeps the line.

What the lab shows

Each result above is a real build. Here's the pattern:

SituationWhat's in your bundle
ESM library, you don't use bigChartbigChart is gone, but chart.js's console.log stays
ESM library with "sideEffects": false, you don't use bigChartchart.js is gone entirely
ESM library, you do use bigChartchart.js stays, console.log included. The flag only affects files you don't use
CommonJS library with module.exports = { … }everything stays, flag or no flag

The sideEffects flag

"sideEffects": false in a package's package.json is a promise from the package author: "no file in this package does anything just by being imported; if you use none of a file's exports, delete it." You can also list the files that do have side effects:

node_modules/some-ui-lib/package.json
{
  "name": "some-ui-lib",
  "sideEffects": ["*.css", "./src/polyfills.js"]
}

Library authors set this; app authors mostly benefit from it. If you publish a package (or an internal shared library used by several of your apps), setting it correctly is one of the best things you can do for the apps that use it. But get it wrong and the bundler will delete code that was supposed to run, such as an imported CSS file.

The PURE annotation: one call at a time

Sometimes a single line is the problem. export const registry = buildRegistry(); calls a function at load time, so the bundler keeps the call even if nobody uses registry, because the function might have a side effect. A comment can promise otherwise:

fancy-utils/chart.js
export const registry = /* @__PURE__ */ buildRegistry();

Why CommonJS defeats it

In the lab, the CommonJS version kept every function whatever you imported. module.exports = { formatDate, slugify, bigChart } builds one object at run time, and any code holding that object could read any property later, maybe with a computed name like utils[name]. The bundler can't prove a function is unused, so it keeps them all, inside a wrapper function that imitates module.exports.

The everyday version of this is lodash:

✕ CommonJS build: the whole library comes along
import { debounce } from "lodash";
✓ ESM build: only what you import
import { debounce } from "lodash-es";
Practitioner§08

Code splitting: chunks and hashes

So far every build produced one JavaScript file. That's great until the app grows: a user opening your login page shouldn't have to download the chart library used only on the reports page. Code splitting cuts the output into several files, called chunks, so each page downloads only what it needs.

One import() = one split point

You ask for a split by loading a module with a Dynamic import instead of a normal import declaration. Our example app has a main page and two pages loaded on demand:

src/main.js
import { slugify } from "fancy-utils";

document.querySelector("#app").textContent = slugify("Hello World");

document.querySelector("#chart").onclick = async () => {
  const { drawChart } = await import("./chart.js"); // ← split point
  drawChart(document.querySelector("#app"));
};
document.querySelector("#settings").onclick = async () => {
  const { openSettings } = await import("./settings.js"); // ← split point
  openSettings(document.querySelector("#app"));
};

chart.js (and settings.js, which looks the same) also uses slugify:

src/chart.js
import { slugify } from "fancy-utils";

export function drawChart(el) {
  el.textContent = "chart for " + slugify("Q3 Sales");
}

The real build output:

dist/assets/chart-DHzPD8Jy.js     0.11 kB │ gzip: 0.12 kB
dist/assets/settings-BWY-k0pW.js  0.12 kB │ gzip: 0.12 kB
dist/assets/index-D-SPEeqF.js     2.70 kB │ gzip: 1.24 kB

Three chunks. The entry chunk index-… loads with the page; chart-… and settings-… load on the first click of their button. Rolldown didn't copy slugify into all three files. It left it in the entry chunk and made the other two import it from there. Here's the whole chart chunk, minified:

dist/assets/chart-DHzPD8Jy.js
import{t as e}from"./index-D-SPEeqF.js";function t(t){t.textContent=`chart for `+e(`Q3 Sales`)}export{t as drawChart};

import { t as e } means "import the export called t and call it e here". The entry chunk exports slugify under the short internal name t, and the minifier renamed it again locally. Notice the entry chunk's file name is written inside the chart chunk. That will matter in a moment.

Most of the entry's 2.70 kB isn't your code: about 1.8 kB is Vite's preload helper and about 0.5 kB is the Modulepreload polyfill, both minified. When a chunk loaded with import() needs other chunks or CSS, the helper adds modulepreload links for all of them at once, so the browser doesn't discover them one by one (the waterfall from §02, in miniature).

Content hashes: why file names look like index-D-SPEeqF.js

The D-SPEeqF part is a Content hash: a fingerprint computed from the file's contents. It solves a real caching dilemma:

  • You want browsers to keep your JS for a long time, so repeat visits are instant.
  • You want users to get the new JS the moment you deploy.

With hashed names you get both. Serve everything in assets/ with the Cache-Control header max-age=31536000, immutable ("keep for a year, never even re-check"). When the content changes, the name changes, so it's a different URL the browser has never seen. Only index.html (which isn't hashed) must be re-checked on every visit, so it can point at the new names. The headers themselves are explained in Caching §HTTP.

The surprise: one edit can rename several files

A file's hash depends on its whole text, and that text includes the names of the other chunks it mentions: the entry names chart and settings (to load them on demand), and both pages name the entry (to import slugify). So a change can ripple outwards, one hop at a time. Try it:

Edit one file: which names change?
main.js loads chart.js and settings.js on demand, and all three use slugify from fancy-utils. File names are real Vite 8 build output.
assets/index-D-SPEeqF.jsentry: main.js
contains the names: "./chart-…""./settings-…"
assets/chart-DHzPD8Jy.json demand: chart.js
contains the names: "./index-…"
assets/settings-BWY-k0pW.json demand: settings.js
contains the names: "./index-…"

Pick a file to edit. A new file name means every returning visitor downloads that file again.

With default chunking, any edit renamed all three files. Fix a typo in chart.js: chart gets a new name, so the entry (which contains "./chart-HASH.js") gets a new name, so settings (which imports from "./index-HASH.js") gets a new name. Every returning visitor re-downloads code that didn't change.

The cause is that the pages import shared code from the entry chunk. Move the shared code into its own chunk and nothing needs to name the entry any more, so the ripple stops there:

vite.config.js
export default {
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [{ name: "utils", test: /fancy-utils/ }], // ← modules matching test go into utils-[hash].js
        },
      },
    },
  },
};

The same idea at a bigger scale is the classic vendor chunk: put node_modules code (which changes when you upgrade packages, not every day) into its own files, so your daily deploys don't invalidate it.

Practitioner§09

Minification and source maps

Minification: same program, fewer bytes

To Minify code is to rewrite it as small as possible without changing what it does. Here's a real before and after (Vite 8's Oxc minifier, 373 bytes in, 164 out). We'll use this same program for the rest of the section:

What you wrote: src/main.ts
function calculateTotal(items: { price: number; qty: number }[]): number {
  let total = 0;
  for (const item of items) {
    total += item.price * item.qty;
  }
  return total;
}

const cart = [{ price: 3, qty: 2 }, { price: 10, qty: 1 }];
if (import.meta.env.DEV) console.log("cart", cart);
document.querySelector("#out")!.textContent = `Total: ${calculateTotal(cart)}`;
→ What ships: dist/assets/index-DqiFATXp.js
function e(e){let t=0;for(let n of e)t+=n.price*n.qty;return t}var t=[{price:3,qty:2},{price:10,qty:1}];document.querySelector(`#out`).textContent=`Total: ${e(t)}`;

What changed:

  • Whitespace and comments removed.
  • Local names shortened. calculateTotal → e, total → t, cart → t (a different scope). Property names like price are kept, because other code could read them by name.
  • Dead branches removed. import.meta.env.DEV is false in a build (§10), so if (false) … was deleted. This is called dead-code elimination.
  • Small rewrites. const became let here and "#out" became a template string; elsewhere 1000 becomes 1e3. Each saves a byte or two.

Yes, the function e takes a parameter also called e. Inside the function the parameter hides the outer name, which is legal JavaScript and saves a character.

Source maps: the way back

Now a user opens a page that has no #out element. querySelector finds nothing and returns null, and setting .textContent on null throws. We ran exactly that in Chromium, and the error report said:

TypeError: Cannot set properties of null (setting 'textContent')
    at index-DqiFATXp.js:1:147

Line 1, column 147, and everything is on line 1. Useless on its own.

A Source map fixes that. It's a .map file written next to each output file, which says, for each piece of the minified code, which original file, line, column and name it came from. Turn it on with build.sourcemap: true. DevTools and error trackers (services like Sentry that collect errors from your users' browsers) read it and point you at src/main.ts line 11 instead.

Click the error report at the top of the explorer to decode it, then hover over other pieces of the minified code. Both files are real build output, and the decoding runs live in your browser:

Source map explorer
A real minified file and its real .map. Hover or click any piece of the minified code (or focus it and use ← →) to see where it came from.
Minified: dist/assets/index-DqiFATXp.js45 mapped pieces
function e(e){let t=0;for(let n of e)t+=n.price*n.qty;return t}var t=[{price:3,qty:2},{price:10,qty:1}];document.querySelector(`#out`).textContent=`Total: ${e(t)}`;
Original: src/main.ts (from sourcesContent)
1function calculateTotal(items: { price: number; qty: number }[]): number {
2 let total = 0;
3 for (const item of items) {
4 total += item.price * item.qty;
5 }
6 return total;
7}
8
9const cart = [{ price: 3, qty: 2 }, { price: 10, qty: 1 }];
10if (import.meta.env.DEV) console.log("cart", cart);
11document.querySelector("#out")!.textContent = `Total: ${calculateTotal(cart)}`;
How this piece was decoded
Segment text in mappings: SAASA
Decoded numbers (each relative to the previous segment): [9, 0, 0, 9, 0]
Minified column: 10
Original position: line 1, column 10
The map counts lines and columns from 0; we add 1 to match what your editor shows.
Original name: calculateTotal (minified to e)

What's inside a .map file

Here's the real file, shortened:

dist/assets/index-DqiFATXp.js.map
{
  "version": 3,
  "file": "index-DqiFATXp.js",
  "sources": ["../../src/main.ts"],
  "sourcesContent": ["function calculateTotal(items: …"],
  "names": ["calculateTotal", "total", "item", "items", "cart"],
  "mappings": "AAAA,SAASA,EAAe,EAAiD,CACvE,IAAIC,…"
}
  • sources: the original files. sourcesContent: their full text, so tools don't need your repo.
  • names: original identifiers that were renamed.
  • mappings: the positions. Commas separate pieces, semicolons separate lines of the minified file. Each piece like SAASA is up to five small numbers written in VLQ (a compact way to write numbers in Base64 letters): minified column, which source, original line, original column, which name.

Each number is stored relative to the previous piece, so most are tiny (0, 1, 2) and fit in a single letter. That's how a map for a 12 MB bundle stays a manageable size.

The minified file ends with a comment pointing at its map, which is how DevTools finds it:

//# sourceMappingURL=index-DqiFATXp.js.map
Practitioner§10

Environment variables and modes

On a server, an environment variable is read while the program runs: Node reads process.env.DATABASE_URL at start-up, from the machine it's running on. A browser has no environment to read from. So a front-end build tool does something different, and the difference matters for security.

Build time, not run time

Vite exposes settings through import.meta.env. In a production build, each import.meta.env.SOMETHING is replaced with its value, as text, in your code. After the build there's no variable left, only a string sitting in a JS file.

What import.meta.env becomes
Same source, three commands. Outputs are real (Vite 8.3.5).
.env files on disk
VITE_API_URL=https://api.example.com
DATABASE_PASSWORD=hunter2
src/main.js
const api = import.meta.env.VITE_API_URL;
const secret = import.meta.env.DATABASE_PASSWORD;
if (import.meta.env.DEV) console.log("debug panel on");
console.log(api, secret, import.meta.env.MODE);
What the browser getsfiles read: .env
import.meta.env = {"BASE_URL": "/", "DEV": true, "MODE": "development", "PROD": false, "SSR": false, "VITE_API_URL": "https://api.example.com"};
const api = import.meta.env.VITE_API_URL;
const secret = import.meta.env.DATABASE_PASSWORD;
if (import.meta.env.DEV) console.log("debug panel on");
console.log(api, secret, import.meta.env.MODE);

In dev, Vite puts one object at the top of each file that uses import.meta.env. It contains the built-in values (SSR means server-side rendering, false in the browser) plus every variable starting with VITE_. DATABASE_PASSWORD is not in it, so secret is undefined.

The rules, from the real outputs above

  1. Only names starting with VITE_ are exposed. DATABASE_PASSWORD was in .env but came out as undefined. The prefix is a guard against accidentally shipping server secrets that live in the same file.
  2. Mode files override .env. npm run build uses mode production and reads .env, then .env.production on top. vite build --mode staging reads .env and then .env.staging on top, instead of .env.production. Files ending in .local (like .env.local) are loaded too and are meant to stay out of git.
  3. Built-ins are always there. MODE, DEV, PROD, BASE_URL. Because DEV becomes the literal false in a build, if (import.meta.env.DEV) { … } is deleted completely: a free way to keep debug tools out of production.
  4. Values are baked in. Change .env.production after building and nothing happens. You have to build again.

Everything in the bundle is public

This is the rule that matters most. A VITE_ variable is not a secret. Every visitor downloads the bundle, and the value sits in it as plain text, findable with View Source or a search in DevTools. The VITE_ prefix only means "I agree this is public."

✕ A secret in the front-end
.env
VITE_STRIPE_SECRET_KEY=sk_live_51H…
VITE_OPENAI_API_KEY=sk-proj-…
✓ Keep the secret on a server you control
.env
# public by design: fine in the bundle
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51H…
VITE_API_URL=https://api.shop.com
# secrets live only in your backend's environment

If the front-end needs to use a secret key, it calls your backend, which holds the key and makes the call itself. For where secrets should live and how they leak, see Secrets.

Staff-level§11

Case study: this site's own build

Everything so far used toy examples. Here's a real project you're looking at right now, with a real before and after: until recently this site shipped one 15.9 MB JavaScript file to every visitor. Every number below was measured on 31 October 2026 with the site's 86 topics, by building the code just before the fix and just after it.

The whole build setup: two files

package.json (the relevant parts)
{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": { "react": "^19.3.1", "react-dom": "^19.3.1", "react-router": "^8.5.1" },
  "devDependencies": {
    "@mdx-js/rollup": "^3.1.1",
    "@tailwindcss/vite": "^4.3.3",
    "@vitejs/plugin-react": "^6.1.2",
    "vite": "^8.3.5"
  }
}

"type": "module" makes every .js file ESM (§03). The scripts are what npm run dev / build / preview run. The split between dependencies and devDependencies is a convention that doesn't change the bundle: Vite bundles whatever your code imports, from either list.

vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import mdx from "@mdx-js/rollup";
import remarkGfm from "remark-gfm";
import remarkCodeMeta from "./src/lib/remark-code-meta.js";
import sectionMeta from "./src/lib/section-meta-plugin.js";

// One lazy chunk per topic: its sections, widgets, data files and the glossary entries it uses
// (src/lib/section-meta-plugin.js). topic.js loads up front; glossary.js is only read at build time.
const topicChunk = (id) => {
  const entry = /^\0virtual:atlas\/topic\/(.+)$/.exec(id);
  if (entry) return `topic-${entry[1]}`;
  const m = /\/src\/topics\/([^/]+)\/(.+)$/.exec(id.split("?")[0]);
  if (!m || id.startsWith("\0") || m[2] === "topic.js" || m[2] === "glossary.js") return null;
  return `topic-${m[1]}`;
};

export default defineConfig({
  base: "./",
  plugins: [
    sectionMeta(),
    { enforce: "pre", ...mdx({ remarkPlugins: [remarkGfm, remarkCodeMeta] }) },
    react({ include: /\.(jsx|js|mdx)$/ }),
    tailwindcss(),
  ],
  build: {
    // The entry is React, the router, the catalog and the section index (~500 kB, ~165 kB gzipped);
    // topic content is split per topic. Warn if the entry grows well past that.
    chunkSizeWarningLimit: 600,
    rolldownOptions: { output: { strictExecutionOrder: true, codeSplitting: { groups: [{ name: topicChunk, debugName: "topics", includeDependenciesRecursively: false }] } } },
  },
});

That's the file exactly as it is today. Start with the parts that were there from the beginning:

  • base: "./" makes the build write ./assets/index-….js instead of /assets/… into index.html. With relative URLs the dist/ folder works from any sub-path or even straight from disk, which suits a static site.
  • Plugins hook into the build's steps (§04). The MDX plugin compiles every section file into a React component; tailwindcss() generates the CSS for the classes the code uses.
  • enforce: "pre" makes the MDX plugin run before the others. Order matters: the MDX plugin outputs JSX, and the React plugin then has to turn that JSX into function calls. Run them the other way round and the React plugin would see Markdown it can't parse.
  • react(…) turns JSX into function calls (§06) and adds Fast Refresh, React's version of HMR (§05) that keeps component state when you save. include: /\.(jsx|js|mdx)$/ tells it to process .mdx files too, after MDX has turned them into JSX.

sectionMeta(), topicChunk and the whole build block are the fix. To see why they exist, start with what the build used to produce.

Before: one file with everything in it

$ npx vite build        # the code just before the fix
✓ 1595 modules transformed.
dist/assets/index-Dmf66rsi.css        211.12 kB │ gzip:    31.03 kB
dist/assets/index-C9Bp3tRk.js      15,923.10 kB │ gzip: 4,614.36 kB

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application

One JavaScript file of 15.9 MB (4.6 MB gzipped). (Three other JS files that only ever loaded on demand, such as a demo service worker used by one topic, are left out of the listing. So: 1 main file plus 3 on demand.) Vite even printed a warning. A visitor who wanted to read one topic downloaded all 86, before seeing anything. Where did it all come from? We added a 20-line plugin to the build that adds up how much code each source file contributes to the main file:

This site's main JavaScript file, before and after
What the big file every visitor downloads first is made of, measured before minification. Both views use the same scale; the dashed outline shows where the topic content used to be.
Topic content (86 topics: sections + widgets)
21,903 kB
react-dom
526 kB
Shared glossary (base.js)
298 kB
react-router
82 kB
Everything else
357 kB

23,166 kB of code before minification, about 95% of it topic content. After minification: one 15.9 MB file, 4.6 MB gzipped.

About 95% was topic content: sections and widgets. React, the router and the app shell were a rounding error next to it. The cause was one option on one line:

src/topics/index.js (before)
const metas = import.meta.glob("./*/topic.js", { eager: true });
const sectionFiles = import.meta.glob("./*/sections/*.mdx", { eager: true }); // ← here

eager: true is a reasonable choice for a small site: it keeps the code simple (no loading states) and every page is instant once loaded. It stopped being reasonable at 86 topics.

The fix that didn't work

The site's search box indexes every section's meta (title, blurb, keywords), so the metadata has to be available up front, even if the content isn't. The obvious idea: import meta eagerly and the content lazily, from the same files. We tried it, and the main file didn't shrink at all. Rolldown printed:

[INEFFECTIVE_DYNAMIC_IMPORT] …/03-modules.mdx is dynamically imported by
…/topics/index.js but also statically imported by …/topics/index.js,
dynamic import will not move module into another chunk.

A module that's imported statically anywhere is already in that chunk, so a dynamic import of it can't move it out. A module is the unit of splitting: you can't split half a file. The metadata and the content had to become different modules.

The fix that worked

1. A small index, generated at build time. The new plugin, src/lib/section-meta-plugin.js, reads each section's export const meta = { … } straight from the .mdx text when the build first asks for one of its modules (a small scanner, no MDX compiling; it reads all 1,210 files once per build and reuses the result), and offers the results as virtual modules:

ModuleWhat's in itLoaded
virtual:atlas/section-indexeach section's id, level and titleup front (table of contents, roadmaps)
virtual:atlas/section-searchblurbs and keywordson the first search
virtual:atlas/topicsone loader function per topicup front (small)
virtual:atlas/topic/<id>that topic's sections, plus only the glossary entries its <Term>s usewhen you open the topic

2. One chunk per topic. topics[id].load() now calls the topic's loader, a dynamic import(). The topicChunk function in the config gives every module under src/topics/<id>/ (and that topic's virtual module) the chunk name topic-<id>, so each topic's sections, widgets and data travel together as one file. For topic.js it returns null, so that file stays in the entry: the home page needs every topic's title.

3. includeDependenciesRecursively: false. A chunk group is one rule in codeSplitting.groups: Rolldown calls its name function for every module, and a string puts the module in the chunk of that name, while null leaves it to the default splitting. By default a chunk group also pulls in everything its modules import. Here that meant React itself was dragged into a topic chunk, and the entry ended up statically importing five topic chunks. With the flag off, a group takes only the modules its name function claims, and shared code stays in the entry or in small shared chunks (React, the Quiz and Stepper components).

4. strictExecutionOrder: true. Once modules are moved into chunks by hand, the order in which their top-level code runs could change: a module could run before something it depends on. This option makes Rolldown add a little bookkeeping so every module still runs in exactly the order the imports say, and Rolldown's docs recommend it alongside includeDependenciesRecursively: false. The visible effect: index.html now loads a tiny index-….js (one line, 50 bytes) that calls into the real main chunk, software-atlas-….js, plus a 0.8 kB rolldown-runtime chunk. Topic chunks got about 3% bigger.

5. A warning limit that means something. chunkSizeWarningLimit: 600 makes Vite warn if the main chunk grows well past its new ~500 kB, instead of a 500 kB limit that was ignored because it was always exceeded.

One intermediate attempt is worth a sentence: simply making the glob lazy, with one () => import() per .mdx file, put 186 kB of loader code into the entry for 1,210 files. Loading by topic needs one loader per topic, not per file, which is why virtual:atlas/topics exists.

After

$ npx vite build        # today
✓ 1599 modules transformed.
dist/assets/software-atlas-BXh9pOs3.css      211.71 kB │ gzip:  31.19 kB
dist/assets/index-D5E1kkG6.js                  0.05 kB │ gzip:   0.07 kB
dist/assets/rolldown-runtime-CNOWq4Kh.js       0.81 kB │ gzip:   0.47 kB
dist/assets/react-CdlCbvX4.js                  7.87 kB │ gzip:   3.01 kB
dist/assets/topic-async-await-DI8-YiXU.js    149.13 kB │ gzip:  45.27 kB
dist/assets/section-search-B3haffUa.js       348.77 kB │ gzip: 125.70 kB
dist/assets/software-atlas-essHWEVv.js       509.11 kB │ gzip: 166.68 kB
… plus 85 more topic-<id> chunks and a few shared or on-demand ones
  (one is react-dom/server, 202 kB, used only by a demo in Rendering Strategies)
✓ built in 8.13s

Ninety-six JavaScript files: four that load with every page (the one-line entry, the main chunk, React and the runtime) and 92 that load only when needed. Before, it was one main file plus three on demand. The 86 topic chunks are 27 to 77 kB gzipped (median 55 kB). We loaded the built site in a headless browser and recorded every file it fetched:

  • Home page: the tiny entry, the main chunk, the small React and runtime chunks, and the CSS. About 200 kB gzipped, down from more than 4.6 MB.
  • Opening this topic: the same files, plus topic-build-tools (about 55 kB gzipped) and the shared Quiz, Stepper and src chunks (about 3 kB).
  • First search: section-search (126 kB gzipped), once.

The build takes about 8 seconds on our machine, slightly faster than the roughly 10 seconds before the split. (A first version of the plugin re-read every section and glossary file once per topic, and builds took about 24 seconds. That's a cost that grows with the square of the topic count, and caching the read once per build removed it: measure your build tool too.) Vite's timing report now puts most of the time in the MDX transform; the exact share varies from run to run.

What's in the main chunk is mostly react-dom, every topic's topic.js (titles and pins for the home page), the section index and react-router.

Staff-level§12

Production: deploys, budgets, libraries and tool choice

The build works on your machine. These are the problems that show up once it's part of a team's daily deploys.

Deploys: old tabs and missing chunks

Picture this. A user opened your app at 9:00, so their tab runs yesterday's index-AAA.js, which knows the chunk Reports-OLD.js. At 10:00 you deploy. At 10:05 they click "Reports", and the tab asks for Reports-OLD.js. If the deploy replaced the folder, that file is gone: 404, and the page breaks.

Two habits prevent it:

  1. Keep old assets for a while. Hashed names never collide, so new files can sit next to old ones. Delete assets older than a few days, not on every deploy.
  2. Upload in the right order. First the new assets/, then the new index.html. In the other order, there's a moment when index.html points at files that don't exist yet.

And as a last line of defence, Vite fires an event when a chunk fails to load. Reloading picks up the new index.html:

src/main.jsx
window.addEventListener("vite:preloadError", (event) => {
  event.preventDefault();      // ← tell Vite we handled it, don't throw
  window.location.reload();    // ← fetch the new index.html and its new chunks
});

(Guard against reload loops in real code, for example by remembering in sessionStorage that you already reloaded once.)

Budgets: catch bundle growth in review, not in production

Bundles grow one reasonable-looking import at a time. Someone adds a date library for one format call and the entry chunk gains, say, 70 kB. Nobody notices, because the app still works.

  • Look at the build log in CI. Vite prints every chunk with its gzip size. A script can fail the build when the entry chunk crosses a limit, and Vite's build.chunkSizeWarningLimit sets when it warns.
  • Visualize. Tools that draw the bundle as a Treemap (for example rollup-plugin-visualizer; check it supports your Vite version) make "what's big?" obvious. We used a 20-line plugin for §11.
  • Measure what users get, too. Size is a proxy; load speed is the goal. Web Performance covers budgets and Core Web Vitals.

Publishing a library is a different job

An app bundles everything into files for browsers. A library you publish to npm should do almost the opposite:

  • Let the app supply shared frameworks. List React (and anything else the app must have exactly one copy of) under peerDependencies, which makes it a Peer dependency: npm installs the app's copy and your library uses that one. Two copies of React in one app is a classic bug (hooks break with an "invalid hook call" error). Other runtime packages go under dependencies, so the app's bundler can share one copy between libraries.
  • Don't bundle either kind into your build. Mark them as external in the library build, so your dist/ contains only your own code.
  • Ship ES modules so apps can tree-shake you (§07), and set "sideEffects" truthfully.
  • Declare entry points with the "exports" field:
package.json (a library)
{
  "name": "fancy-ui",
  "type": "module",
  "peerDependencies": { "react": "^19.0.0" },
  "exports": {
    ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }
  },
  "sideEffects": false
}

Shipping both an ESM and a CommonJS build (as above) is common, but it comes with the Dual package hazard: if one app ends up loading both copies, anything stored at module level exists twice. Since Node 22.12 can require() ES modules (§03), many libraries now ship ESM only. Vite's library mode (build.lib) handles the bundling side.

Reproducible builds

The same commit should produce the same bundle on every machine. In CI, install with npm ci, which installs exactly what the Lockfile says and fails if package.json and the lockfile disagree. Pin the Node version too: an .nvmrc file (read by the nvm version manager and many CI setups) or the engines field in package.json. Content hashes are a handy check: the same input should give the same file names.

Choosing a tool

ToolWhat it's known for
Vite (Rolldown + Oxc in v8)Default for new React, Vue and Svelte apps. Unbundled dev server, Rollup-style plugins.
webpackThe long-time standard. Huge plugin ecosystem, slower, bundles in dev too. Many big apps still use it.
RspackA Rust reimplementation of webpack's API, for teams that want webpack compatibility with more speed.
TurbopackBuilt by Vercel for Next.js.
esbuildVery fast bundler and transpiler (Go). Often used for libraries, scripts and server code rather than full apps.
ParcelZero-config bundler: point it at an index.html.

This landscape changes quickly, so check the current docs before you commit to anything. The concepts in this topic (module graph, tree shaking, chunks, hashes, source maps, build-time env) apply to all of them; only the config names differ.

Practitioner§13

Pitfalls

01

Believing the build checks your types

Vite deletes TypeScript types without checking them, so const n: number = "x" builds and ships (§06). Fix: run tsc --noEmit in CI and before vite build.

02

Putting a secret in a VITE_ variable

Every import.meta.env.VITE_… value is pasted into the bundle as text, and every visitor downloads it (§10). Fix: only public values (publishable keys, API URLs) go in the front-end. Anything secret stays on a server, and the front-end calls that server.

03

Reading process.env in browser code

Habit from Node. In a Vite 8 build, we found process.env.NODE_ENV replaced with "production", but process.env.API_URL turned into {}.API_URL: silently undefined, no error. Fix: use import.meta.env.VITE_API_URL.

04

Importing a whole library for one function

import { debounce } from "lodash" pulls in CommonJS lodash, which can't be tree-shaken (§07). Barrel files with side effects do the same thing to your own code. Fix: prefer ESM packages (lodash-es) or direct imports, and check the build log when a chunk suddenly grows.

05

Only ever testing in npm run dev

Dev and build take different paths (§05). Bugs that only show in the build: CommonJS interop, CSS order, and imports whose upper/lower case doesn't match the file name (./button.jsx for Button.jsx). That works on macOS's case-insensitive disk and fails on a Linux build server. Fix: npm run build && npx vite preview before merging, and build in CI.

06

Caching index.html, or not caching assets

Cache index.html for a long time and users keep loading old file names after a deploy. Don't cache the hashed assets and every visit re-downloads megabytes. Fix: assets/* → Cache-Control: max-age=31536000, immutable; index.html → no-cache (§08).

07

Deleting the previous deploy's chunks

Open tabs still run the old entry chunk and will ask for old chunk names (§12). Fix: keep old assets for a few days, upload assets before index.html, and reload on vite:preloadError.

08

Importing a module both statically and dynamically

If any file imports Reports.jsx normally, import("./Reports.jsx") elsewhere can't move it into its own chunk: it's already in the main one. Rolldown warns with INEFFECTIVE_DYNAMIC_IMPORT (§11). Fix: search for the static import and remove it, or split the module into a small part used everywhere and a big part loaded on demand.

Practitioner§14

Check yourself: predict what the build does

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
Your .ts file has a type error. What does vite build do?
const total: number = "not a number";
QUESTION 2 / 7
Which import can a bundler tree-shake?
QUESTION 3 / 7
Predict the output

.env contains VITE_TITLE=Shop and STRIPE_KEY=sk_live_123. You run npm run build.

console.log(import.meta.env.VITE_TITLE, import.meta.env.STRIPE_KEY);
QUESTION 4 / 7
Default chunking. You fix one typo in the chart page, which is loaded on demand. The settings page is another on-demand chunk; both use a helper that lives in the entry chunk. Which files get new names?
QUESTION 5 / 7
Your error tracker shows: TypeError at index-BttLW8vI.js:1:84312. What turns that into App.jsx line 42?
QUESTION 6 / 7
Why is npm run dev ready almost immediately, while npm run build on this site takes about 9 seconds?
QUESTION 7 / 7
A package's chart.js runs console.log at the top level and you import nothing from chart.js. Its package.json has no sideEffects field. What stays in your bundle?
Answered 0 / 7
Foundations§15

Cheat sheet

📦
Why bundleBrowsers can't resolve "react", can't run JSX/TS, and discover imports one round trip at a time.
🔍
ESM is analysableimport/export are top-level and static, so tools see the graph without running code. require() isn't.
⚡
Dev ≠ buildDev: native ESM, transform on request, HMR. Build: Rolldown bundles the whole graph once. Test with vite preview.
🧹
Types are stripped, not checkedAdd tsc --noEmit to CI. Syntax is lowered for build.target; missing methods need polyfills.
🌳
Tree shakingUnused exports go. Top-level code with possible side effects stays unless "sideEffects": false or /* @__PURE__ */.
✂️
import() = new chunkShared code goes to one chunk. A module imported statically anywhere won't split off.
🏷️
Content hashesCache assets/ forever (immutable); make index.html always revalidate (no-cache). Chunk names inside other chunks make hashes ripple.
🗺️
Source mapsMap minified positions back to file, line, column, name. sourcesContent = your source; use "hidden" if needed.
🔑
import.meta.env is publicReplaced as text at build time. Only VITE_ names are exposed, and they're visible to everyone.
📏
Measure the bundleRead the build log. This site went from one 15.9 MB file to a 509 kB main chunk plus one chunk per topic, once metadata and content became separate modules.
If you want to…Do thisAvoid
Catch type errorstsc --noEmit in CItrusting vite build
Load a heavy page laterimport("./Page.jsx") / React.lazyalso importing it statically somewhere
Keep library code cached across deploysits own chunk via codeSplitting.groupsone chunk that changes every deploy
Use a config value in the browserimport.meta.env.VITE_X (public only)secrets, process.env
Ship different config per environment from one buildfetch /config.json at start-uprebuilding per environment if one tested artifact matters
Debug a production errorsource maps uploaded to your error trackerdisabling minification
Publish a libraryESM, "exports", truthful "sideEffects", deps not bundledbundling React into your package
Find out why the bundle is biga treemap of the build, then fix the biggest thingguessing
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.