Command Palette

Search for a command to run...

Hectal
PHASE 23Advanced ~30 min· topic 1 of 6

Topic 23.1

What a Build Does

In one line

A production build turns your many source files into a few small, fast files in dist/: it transpiles JSX and TypeScript, bundles modules together, removes code nothing uses (tree shaking), minifies what's left, names files by a hash of their content and can write source maps that point back to your original code.

Think of it like this

A school trip with forty children. You can't send each child to the bus one by one through every door of the school (that's the browser fetching hundreds of separate files). So the teacher lines them up, packs them into two buses (bundling), leaves behind the children who aren't going (tree shaking), tells everyone to take only a small backpack (minifying), and keeps a register that says who sits in which seat (the source map), so a lost child can always be traced back to their class.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Build
The step that turns your source code into the optimised files you put on a server.
dist/
The folder a Vite build writes: index.html plus hashed files in dist/assets/.
Transpile
Rewrite code into a form the browser can run, such as JSX into jsx() calls and TypeScript into JavaScript.
Bundle / chunk
Bundling combines many modules into a few files. Each output file is a chunk.
Entry
The file the bundler starts from, usually src/main.jsx, reached from index.html.
Tree shaking
Removing exports that nothing imports, so they never reach the browser.
Side effect
Code that does something just by being imported, such as changing a global. Bundlers must keep it.
Minify
Make code smaller without changing what it does: remove spaces and comments, shorten names.
Content hash
A short code made from a file's content and put in its name. Same content, same name; new content, new name.
Source map
A .map file that links each spot in the minified output back to your original file, line and column.

Step by step

01The pipeline at a glance

Every build tool does the same steps in roughly this order. Vite 8 uses Rolldown for bundling and Oxc for transpiling and minifying, both written in Rust, which is why builds take hundreds of milliseconds instead of seconds.

The pipeline at a glancediagram
Rendering diagram…

02Run the build and read every line

This is the real output for a fresh Vite 8 React app with one extra lazy(() => import("./Chart.jsx")). Each line is one file in dist/, with its size before and after gzip compression (servers send files gzipped or Brotli-compressed, so the gzip number is closer to what users download).

Notice the separate Chart chunk: the dynamic import split it out. The big index chunk is mostly React and React DOM (about 220 kB raw, 69 kB gzipped).

Terminalwhole filebash
$ npm run build

> app@0.0.0 build
> vite build

vite v8.3.4 building client environment for production...
transforming...
✓ 19 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                   0.45 kB │ gzip:  0.29 kB
dist/assets/index-nqMpL4T3.css    1.78 kB │ gzip:  0.81 kB
dist/assets/Chart-CC2n_YEU.js     0.13 kB │ gzip:  0.15 kB
dist/assets/index-D9PM2J-I.js   221.49 kB │ gzip: 69.38 kB

✓ built in 436ms

03Look inside dist/index.html

Vite rewrote your index.html: the <script src="/src/main.jsx"> became the hashed entry chunk, and the CSS was pulled into its own file. This tiny HTML file is the only file whose name never changes, which matters for caching (Topic 23.3).

dist/index.htmlwhole filehtml
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>app</title>
    <script type="module" crossorigin src="/assets/index-D9PM2J-I.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-nqMpL4T3.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

04See tree shaking and minifying in the output

src/money.js exported two functions, formatPrice and convertCurrency; the app imported only formatPrice. Searching the built file finds formatPrice, renamed to d and squeezed onto one line, and no trace of convertCurrency.

src/money.js → dist/assets/index-D9PM2J-I.jswhole filejs
// source
export function formatPrice(cents) {
  return "Rs " + (cents / 100).toFixed(2);
}

export function convertCurrency(cents, rate) {
  return Math.round(cents * rate);
}

// what the build kept (an excerpt of the one-line output)
function d(e){return`Rs `+(e/100).toFixed(2)}

05Turn on source maps

Add build.sourcemap to vite.config.js (or pass --sourcemap on the command line). The build prints each map's size. With true, each JS file ends with a comment that tells DevTools where its map is; with "hidden" the maps are written but the comment is left out.

vite.config.js + Terminalwhole filebash
// vite.config.js
export default defineConfig({
  plugins: [react()],
  build: { sourcemap: "hidden" }, // true | "hidden" | "inline"
});

$ npm run build
...
dist/assets/Chart-CC2n_YEU.js     0.13 kB │ gzip:  0.15 kB │ map:     0.33 kB
dist/assets/index-D9PM2J-I.js   221.49 kB │ gzip: 69.38 kB │ map: 1,045.40 kB

$ ls dist/assets
Chart-CC2n_YEU.js  Chart-CC2n_YEU.js.map  index-D9PM2J-I.js  index-D9PM2J-I.js.map  index-nqMpL4T3.css

06Test the build, not just the dev server

npm run preview serves dist/ the way a static host would (with one difference you'll meet in Topic 23.3: it also answers unknown paths with index.html). Click through the main flows, open DevTools' Network panel, and check that lazy chunks load when expected.

Test the build, not just the dev serverdiagram
Rendering diagram…

Try it yourself

  1. 1

    Import one more function

    In "Tree shaking by hand", add "longDate" to main.jsx's imports from dates.js. Predict the new bytes: line before running. (longDate and pad are both kept now; pad was already kept.)

  2. 2

    Comments don't change the hash

    In the minify example, add a comment line in the text box, then change 100 to 1000. Which edit changes the file name? Why is that exactly what you want for caching?

  3. 3

    Build a real app

    Create a Vite app (Topic 0.4), add a module with two exports and import only one. Run npm run build and search dist/assets/*.js for the unused function's body. Then add build: { sourcemap: true } and look at the last line of the JS file.

Code & diagrams

Tree shaking by hand New tab

This is the idea a bundler applies to your real modules: start at the entry, follow imports, keep what is reached. `pad` is kept because `shortDate` uses it, even though `main.jsx` never imports it directly. `roundTo` is dropped because only the dropped `convertCurrency` used it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

kept: formatPrice, shortDate, pad
dropped: convertCurrency, roundTo, longDate
bytes: 760 -> 180
Minify and hash: why file names change New tab

Edit the code in the box: comments and spaces don't change the minified output, so the name stays the same; change real code and the name changes. Real minifiers parse the code instead of using regular expressions, and Vite uses a stronger hash, but the result is the same idea.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

export function formatPrice(e){const t=e/100;return "Rs "+t.toFixed(2);}
bytes: 177 -> 72
name: money-06a23g6.js
same code again: money-06a23g6.js
one character changed: money-1r9bnq5.js
A lazy chunk downloads only when used New tab

The build turned `import("./Chart.jsx")` into a separate file. The page loads only the entry chunk; the chart's chunk is requested the first time `<Chart />` renders. Open DevTools' Network panel on any real Vite app to see the same thing.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

GET /assets/index-D9PM2J-I.js
files so far: 1
GET /assets/Chart-CC2n_YEU.js
files so far: 2
screen: 📈 Chart (from its own chunk)
What the build did to an environment check (real output)js
// source (src/App.jsx)
console.log("dev?", import.meta.env.DEV, "prod?", import.meta.env.PROD);
if (import.meta.env.DEV) {
  console.log("debug panel enabled");
}

// after vite build: values replaced, then the minifier removes the dead branch
console.log(`dev?`,!1,`prod?`,!0)

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Deploy the dev server

Run npm run dev -- --host on a server and send users there instead of building.

terminal
$ Browser DevTools, Network panel
── what you'll see ──
Hundreds of requests: /src/main.jsx, /src/App.jsx, /node_modules/.vite/deps/react.js ...
Console: development-only React warnings shown to users

Break #2

Expect dev error messages in production

Deploy a build where createRoot is given a missing element, then read the production console.

terminal
$ Browser console (production build)
── what you'll see ──
Uncaught Error: Minified React error #299; visit https://react.dev/errors/299 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.

Break #3

Kill tree shaking with a namespace default import

Replace import { debounce } from "lodash-es" with import _ from "lodash" and use _.debounce.

terminal
$ npm run build
── what you'll see ──
dist/assets/index-<hash>.js grows by tens of kB (gzip): the whole CommonJS lodash is in the bundle

Myth vs fact

Myth

The build checks my TypeScript types.

Fact

Vite strips types without checking them. Run tsc --noEmit (or tsc -b) in CI or as part of your build script to catch type errors.

Myth

Minified code is secure or secret.

Fact

Minifying only makes code shorter. Anyone can read and pretty-print your bundle. Never put secrets in front-end code (Topic 23.2).

Myth

If it works with npm run dev, the build works too.

Fact

Dev and build use different pipelines and different React builds. Test the real build with npm run preview before you deploy.

Myth

Source maps slow the app down.

Fact

Browsers download a map only when DevTools is open. The cost is that public maps reveal your original source, which is why "hidden" maps uploaded to an error tracker are common.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Vite 8 replaced the old esbuild-for-dev, Rollup-for-build pair with Rolldown for both, which removed a whole class of "works in dev, breaks in build" differences. build.rollupOptions is now a deprecated alias of build.rolldownOptions, so old config still works with a deprecation notice.

  • ▸

    process.env.NODE_ENV and import.meta.env.* are replaced with literal values at build time, and the minifier then removes branches that can never run. Libraries rely on this to ship development-only checks for free; React's whole dev build disappears this way.

  • ▸

    Libraries can mark themselves "sideEffects": false in package.json to tell bundlers it's safe to drop any unused module completely. If your own code imports CSS or polyfills for their side effects, list those files in sideEffects or they may be dropped.

  • ▸

    Content hashes depend on the content and the content of what they import, so a change in one module can change several chunk names. Rolldown keeps unchanged chunks' names stable, which is what makes long-term caching effective: users re-download only the chunks that really changed.

Remember this

  1. 1

    Your source code is not what the browser runs. You write JSX, maybe TypeScript, split into dozens of modules that import each other and packages from node_modules. Browsers can't run JSX or TypeScript, and downloading hundreds of small files one after another is slow. A build tool (Vite, Next.js's Turbopack, webpack) takes your source and produces files the browser can run quickly. npm run build in a Vite project runs vite build; the result goes into a folder called **dist/** (short for distribution).

  2. 2

    Transpiling means rewriting code into a form the target can run, at the same level of language. JSX becomes calls to jsx() from react/jsx-runtime, TypeScript types are stripped (deleted, not checked: run tsc --noEmit separately to check them, Topic 12.1), and very new syntax can be rewritten for older browsers. Vite 8 transpiles with Oxc, a fast compiler written in Rust. Its default target is baseline-widely-available: browsers from about early 2023 onwards (Chrome 111+, Firefox 114+, Safari 16.4+), so modern syntax such as ?., ?? and async/await is left as it is.

  3. 3

    Bundling means following every import, starting from the entry file (src/main.jsx), and combining the modules into a few files called chunks. Vite 8 bundles with Rolldown, a Rust bundler. Each dynamic import() (what lazy() uses, Topic 16.8) starts a new chunk that downloads only when that code runs. In the real build below, const Chart = lazy(() => import("./Chart.jsx")) produced a separate Chart-CC2n_YEU.js file next to the main index-D9PM2J-I.js.

  4. 4

    Tree shaking (also called dead code elimination) removes exports that nothing imports. ES modules (import/export, see /javascript) make this possible because imports are fixed at build time: the bundler can see that formatPrice is imported and convertCurrency never is. It keeps only what is reachable from the entry. Tree shaking fails when code has side effects at the top level (code that runs just by being imported, such as changing a global), when you import a whole CommonJS package (require, which can't be analysed the same way), or when a library marks itself "sideEffects": true. That's why import { debounce } from "lodash-es" is small and import _ from "lodash" pulls in the whole library.

  5. 5

    Minifying shrinks the code without changing what it does: comments and whitespace go, local variable names become one letter, true becomes !0, and if (process.env.NODE_ENV !== "production") { ... } blocks that can never run are deleted. That last step is how you get React's production build: no development warnings, no StrictMode double rendering, smaller and faster. In production, React's error messages are replaced by codes: Minified React error #299; visit https://react.dev/errors/299 for the full message or use the non-minified dev environment for full errors and additional helpful warnings. Vite 8 minifies JavaScript with Oxc and CSS with Lightning CSS. Then every file name gets a content hash (index-D9PM2J-I.js): a short code worked out from the file's bytes, which changes whenever the content changes. Topic 23.3 shows why that lets browsers cache files for a year.

  6. 6

    Source maps connect the minified code back to your original files. A .js.map file lists, for each position in the output, the file, line and column it came from. Browser DevTools and error trackers (Topic 23.5) use them to show App.jsx:12 instead of index-D9PM2J-I.js:1:48213. Vite writes them only when you ask: build.sourcemap: true (maps plus a //# sourceMappingURL= comment, so anyone's DevTools can load your original source), "hidden" (maps without the comment: for uploading to an error tracker), or "inline" (the map is embedded in the file, which makes it much bigger).

  7. 7

    Dev is not build. npm run dev doesn't bundle your code: the dev server sends each source file to the browser as a native ES module, transpiled on request, and updates only the changed module when you save (HMR, hot module replacement). Packages from node_modules are pre-bundled once so the browser doesn't fetch hundreds of tiny files. The dev server also uses React's development build, with all its warnings. So a bug can appear only in the build (a missing file that the dev server happened to serve, a dead-code assumption, a tree-shaken side effect). Always test the real build with npm run build then npm run preview, which serves dist/ locally.

Explain it without notes

01

What happens, step by step, when you run npm run build in a Vite React app?

02

What is tree shaking, why do ES modules make it possible, and what stops it from working?

03

Why do built files have hashes in their names?

04

What are source maps and what are the three Vite build.sourcemap settings for?

05

How is npm run dev different from a production build, and why does it matter?

Practice

01

In the tree-shaking example, dates.js gains an export logImport with { size: 20, uses: [] } that the bundler must keep because it has a side effect. Add a sideEffect: true flag and change shake() so such exports are always kept.

02

Write a vite.config.js that writes hidden source maps and raises the chunk size warning limit to 800 kB.

Trade-offs

  • ↔

    More chunks mean better caching and smaller first loads, but more requests and possible waterfalls. Split by route and by heavy optional features, not by every component.

  • ↔

    Public source maps make production debugging easy for you and for everyone else. Hidden maps uploaded to an error tracker keep your source private at the cost of a build step.

  • ↔

    A newer build.target gives smaller, faster code but drops older browsers. Check who your users are (analytics, Topic 23.5) before changing it.

Done when you can

  • I can explain transpiling, bundling, tree shaking and minifying in plain words.

  • I can read Vite's build output and say what each file in dist/ is.

  • I can explain why built files have content hashes in their names.

  • I can turn on source maps and choose between true, "hidden" and "inline".

  • I test the production build with npm run preview before deploying.