Skip to content
advanced Phase 15 · Production Builds and Capstone Project

Production Build Optimization

Tune minification, source maps, and chunk strategy for the final bundle.

40m
0 problems
Topic Progress 0%

What vite build Does to Your Code Before You See a Byte

vite build finishes in 4.2 seconds and the output in dist/ bears little resemblance to what you wrote. Three transforms ran in that time — tree-shaking, minification, and target lowering — and knowing precisely what each one does (and refuses to do) is the difference between a 96 KB entry chunk and a 400 KB one.

Minification is esbuild, and it is on by default:

// vite.config.js
export default defineConfig({
  build: {
    minify: 'esbuild',        // default; 'terser' or false are deliberate swaps
    cssMinify: true,          // default
    assetsInlineLimit: 4096   // assets smaller than this become data: URLs
  },
  esbuild: {
    drop: ['console', 'debugger']   // strip in production without switching minifiers
  }
})

esbuild minifies roughly ten to a hundred times faster than terser, which is why the whole production build stays in single-digit seconds and minification on every deploy is affordable. What you trade: terser's advanced compression passes (pure_getters, deep dead-code elimination). When a requirement says "no console.log in production", you do not need terser for it — the drop option above deletes those calls and their arguments entirely. minify: false has one legitimate use: producing a readable-but-production-shaped bundle locally to debug minified behavior; it never ships.

Tree-shaking is a separate mechanism with a separate rule. Rollup removes exports that no import site references — but only in ES modules that declare their side-effect freedom. Your own source is shaken by default; third-party packages shake only if their package.json declares "sideEffects": false:

import { debounce } from 'lodash-es'   // ships only debounce — package declares sideEffects: false
import _ from 'lodash'                 // CommonJS: ships all of lodash, ~25 kB gzip wasted

The trap lives in the other direction: a package that declares "sideEffects": false while you import 'lib/dist/theme.css' from it can have that CSS shaken away, because the declaration says "importing me changes nothing." When a library's styles vanish after an upgrade, check its sideEffects flag before blaming your CSS pipeline.

The CSS pipeline itself follows the same rules as JS: cssMinify compresses stylesheets, and CSS code splitting attaches each lazily-imported view's stylesheet to that view's chunk — so a route the user never opens costs no CSS bytes either. build.cssCodeSplit: false inlines everything into one stylesheet, which exists for shell environments (Electron, in-app webviews that cache one document) and is the wrong choice for the open web.

Target and inlining finish the job. The default build target only transpiles down to what modern evergreen browsers need (the modules profile in Vite 5, baseline-widely-available in newer releases), so optional chaining and nullish coalescing survive into the output — lowering to es2015 for legacy support typically inflates JS by 5–15% of transpiled helpers. assetsInlineLimit: 4096 inlines assets under 4 KB as base64 data URLs, removing HTTP requests at the cost of making those bytes uncacheable and slightly larger (base64 adds ~33%); raising it to inline a 40 KB icon is a common self-inflicted wound.

Verify instead of trusting. After a build, the output listing shows per-chunk sizes and gzip estimates, and two greps tell you what actually happened:

grep -c "legacyHelper" dist/assets/index-*.js   # unused export → 0 matches means shaken
grep -o "console\.log" dist/assets/*.js | wc -l # drop: ['console'] → 0

The meta-lesson: a production bundle is generated artifacts, not a zip of your mental model — read the emitted files the way you read test output. If you came here from Titles, Meta, and SPA SEO, you already know that what crawlers and users receive is only ever the built output; the next chapter covers how to keep it debuggable without giving the source away.

Readable Production Stack Traces Without Publishing Your Source

Sentry lights up: TypeError: Cannot read properties of undefined (reading 'items') at index-a91d0f33.js:1:184723. Column 184,723 of a minified line is not a stack trace, it is a riddle — and every engineer who has debugged production without source maps has felt the specific helplessness of a location that points at nothing. The sourcemap mode you pick at build time decides whether this alert takes ten minutes or two days to act on, and it also decides how much of your source code you publish to the world.

Vite offers four modes, and the differences are not cosmetic:

// vite.config.js
export default defineConfig({
  build: {
    sourcemap: 'hidden'   // false | true | 'inline' | 'hidden'
  }
})
build.sourcemap Files emitted Stack readable Source exposed Build cost
false (default) none only the minified location no fastest
true .map per asset, sourceMappingURL comment included yes — if the .map files are hosted yes: the maps are served from your origin slower
'hidden' .map per asset, URL comment stripped yes — for your error tracker only maps exist, so they must stay off public hosting slower
'inline' map appended into the JS itself as base64 yes, self-contained source ships inside every JS file users download slowest

The production workflow almost always converges on 'hidden' plus an upload step: CI builds with hidden maps, pushes dist/assets/*.map to the error tracker as a build artifact, and deploys dist minus the maps. When the alert arrives, the tracker translates index-a91d0f33.js:1:184723 back to OrdersTable.vue:142 using its private copy:

# CI (after vite build, before deploy)
sentry-cli sourcemaps upload --org acme --project web-app dist/assets
rsync -av --exclude='*.map' dist/ deploy@web:/var/www/app/

Why not just sourcemap: true for simplicity? Because minified code is not protection, but published maps are a full source disclosure: original file tree, component names, internal API routes, feature-flag names, half-finished admin paths. Anything a security review would classify as internal rides along for free. And why not 'inline', the self-contained option? The map joins the JS file: a 31 KB gzip chunk with an inline map roughly doubles its transferred size, and every byte of it defeats the chunk splitting you did in the previous topic — users download your entire source as a compressed attachment to each script.

Cost awareness also matters for CI: generating and writing .map files roughly doubles the build step's time on a large app, which is a fine trade for production and a pointless one for every pull request. Scope it — sourcemap: 'hidden' on the release pipeline, false on PR preview builds, and the dev server's own inline maps (a dev-only setting, independent of build.sourcemap) untouched.

One boundary case worth knowing for interviews and incident reviews: if a policy forbids hosting maps anywhere in your infrastructure, the fallback is sourcemap: false plus a readable-production-bundle build (minify: false, separate output) kept as a CI artifact for reproducing issues — you lose in-place symbolication and keep source control. And when you do upload maps, attach them to a release identifier (your app version or commit SHA): a stack trace from last Tuesday must symbolicate against last Tuesday's code, not whatever the tracker currently holds. The question interviewers are really asking is "do you know where your source goes in production?" — and the answer starts with which of those four modes you typed.

96 KB on Disk, 31 KB on the Wire: Compression, and the Budget That Stops Creep

Your entry chunk is 96 KB after minification and the network panel says 31 KB transferred. Nobody is lying: the file on disk is 96 KB, and something on the wire compressed it on the way to the browser. Understanding who compressed it, when, and how good that compression is is the last mile of build optimization — and the piece that keeps the number honest as the app grows.

gzip and brotli are wire formats, not build steps. Typical results on JavaScript: a 340 KB minified bundle lands around 105 KB over gzip (≈69% reduction) and roughly 92 KB over brotli-q11 (≈73%). Brotli's high levels are expensive to produce — minutes of CPU for a large bundle — which is exactly why they belong at build time, not request time. Three places the work can happen:

Approach CPU at request time Freshness Where it fits
On-the-fly at the CDN edge (Cloudflare, Netlify) edge CPU per request always correct default for most hosted platforms
Precompressed siblings (.gz/.br files) served by gzip_static/brotli_static zero — sendfile the existing file you must regenerate on every build self-hosted nginx behind real traffic
Dynamic gzip on; at the origin blocks response start on your server always correct low-traffic or uncacheable dynamic responses

Precompression, when you choose it, is a postbuild hook — Vite will not emit these files for you:

# package.json
"postbuild": "find dist -name '*.js' -o -name '*.css' | xargs gzip -9 -k && \
              find dist -name '*.js' -o -name '*.css' | xargs brotli -q 11 -k"
gzip_static on;      # serve index-a91d0f33.js.gz when the client accepts gzip
brotli_static on;    # ...and index-a91d0f33.js.br when it accepts br

Two rules keep this from wasting effort: compress only text — JS, CSS, HTML, SVG, JSON, source maps — because images, video, and fonts are already compressed and re-compressing them burns CPU for ~0 bytes; and min_length thresholds around 1 KB keep tiny responses from paying a header-only round of overhead.

The budgets are what stop the number from rotting. Compression masks creep in local reasoning: a "harmless" 40 KB dependency feels the same as a 12 KB one until you watch the transferred size. The defense is a written per-chunk limit, enforced in CI, so growth is visible in the pull request that caused it instead of the incident that exposes it:

// package.json — size-limit config
"size-limit": [
  { "path": "dist/assets/index-*.js", "limit": "120 KB" },   // gzip-measured by default
  { "path": "dist/assets/*.js", "limit": "150 KB" }
]
# CI gate — exits non-zero when a budget is exceeded
npx size-limit

Anchored budgets for an SPA of this shape: entry chunk ≤ 120 KB gzip, any lazy route chunk ≤ 150 KB, total JS for the initial route ≤ 200 KB. The reasoning is physical: a mid-range phone parses and compiles JavaScript at roughly a megabyte per second, so 50 KB of avoidable JS is 50 ms of main-thread time before your code runs a single line of app logic — and unlike a byte count, that cost compounds with every feature nobody removed.

Wire these checks together — the size listing from vite build, size-limit in CI, precompressed siblings for the origin — and the transfer budget stops being a number in a wiki page. With the bundle built, measured, and debuggable, the remaining question is how the finished artifact reaches users: Deploying a Single-Page App turns dist/ into a live, cacheable, deep-link-safe deployment.

Quiz

1. A teammate sets `build.sourcemap: 'inline'` for the production build. What is the real consequence?

Question 1 options

2. Which pair of statements about tree-shaking is accurate?

Question 2 options

3. An origin server is configured with `brotli_static on;`, but clients never receive .br files. Where must the siblings be produced?

Question 3 options

4. What makes a bundle-size budget actually preventive rather than decorative?

Question 4 options

Flashcards

Question

What is Vite's default minifier, why is it the default, and when would you switch?

Answer

esbuild — 10–100× faster than terser, which keeps whole production builds in single-digit seconds. Switch to terser only for its advanced compression passes; stripping console/debugger needs no switch: use `esbuild: { drop: ['console', 'debugger'] }`.

Question

When does tree-shaking remove a third-party package's unused code, and what is the classic failure?

Answer

When the package ships ESM and declares `"sideEffects": false` in package.json (lodash-es yes, CommonJS lodash no). The classic failure is the reverse: a sideEffects:false package whose imported CSS gets shaken away because the declaration claims imports have no effect.

Question

Compare the four build.sourcemap modes for production.

Answer

false: no maps, unreadable minified stacks, nothing exposed. true: maps hosted publicly — full source disclosure. 'hidden': maps generated without the sourceMappingURL comment — upload them privately to Sentry, keep them off public hosting. 'inline': base64 map inside the JS — self-contained but roughly doubles transferred size and ships source.

Question

gzip vs brotli on JS: typical numbers, and who should do the compression?

Answer

A 340 KB minified bundle → ~105 KB gzip, ~92 KB brotli-q11. High brotli levels are slow to produce but free to serve, so compress at build time (postbuild siblings + gzip_static/brotli_static) or let the CDN do it on the fly; never compress images/video (already compressed).

Question

What is the budget workflow that stops bundle creep?

Answer

Write per-chunk limits (entry ≤ 120 KB gzip, lazy chunk ≤ 150 KB, initial route ≤ 200 KB), measure the emitted dist with size-limit in CI, and fail the PR that exceeds them. Budgets only work when they gate the merge — a post-hoc spreadsheet documents the growth instead of preventing it.

Revision Notes

Key Takeaways

  • 1. esbuild minification is on by default and fast enough to run on every deploy; terser is only needed for its advanced passes — console stripping is an esbuild `drop` option
  • 2. Tree-shaking depends on the package: ESM with sideEffects:false shakes, CommonJS never does, and a wrong sideEffects flag can silently delete a library's CSS
  • 3. Sourcemap mode is a disclosure decision: 'hidden' maps uploaded to a private tracker give readable stacks without publishing your component tree and internal routes
  • 4. Compression belongs at build time or the CDN edge — brotli-q11 siblings cost minutes to produce and nothing to serve; only text assets benefit
  • 5. Budgets only prevent creep when they are written limits enforced in CI at merge time, measured gzip-weighted on the emitted output

Interview Tips

  • • "What does vite build actually do?" — answer in pipeline order: tree-shake unused exports, minify with esbuild, lower to the target browser profile, emit content-hashed assets; then name what it does NOT do (compress on the wire, remove console unless told)
  • • "Our stack traces are unreadable in production — fix it" — build.sourcemap: 'hidden' + upload maps to the error tracker + deploy without them; contrast with inline maps doubling payload and true maps publishing source
  • • "How do you keep the bundle from growing?" — per-chunk budgets in CI (size-limit), gzip-weighted, failing the offending PR; add the physical reasoning (parse cost on mid-range phones) to show why bytes matter twice
  • • "When would you choose brotli over gzip and where is it produced?" — same file, ~10–15% smaller over the wire; produced at build time (q11 siblings) because request-time brotli must use cheap levels; or simply let the CDN handle it

Cheat Sheet

Production Build Optimization

What vite build does: tree-shake (needs ESM + sideEffects: false) → minify (esbuild default) → lower target (modules / baseline-widely-available) → emit hashed assets.

export default defineConfig({
  build: {
    minify: 'esbuild',            // 'terser' only for advanced passes
    sourcemap: 'hidden',          // maps for tracker, not for the public
    assetsInlineLimit: 4096,      // keep small; large inlining breaks caching
    target: 'modules'
  },
  esbuild: { drop: ['console', 'debugger'] }
})

Sourcemaps:

mode readable stacks exposed
false no no
true yes full source via hosted .map
'hidden' tracker-only (upload maps, deploy without them) no
'inline' yes source inside JS; ~2× file size

Compression: JS 340 KB → ~105 KB gzip → ~92 KB brotli-q11. Text only; images already compressed. Precompress in postbuild (gzip -9 -k, brotli -q 11 -k) and serve with gzip_static on; brotli_static on; — or let the CDN compress on the fly.

Budgets (CI-gated, gzip-weighted via size-limit):

"size-limit": [
  { "path": "dist/assets/index-*.js", "limit": "120 KB" },
  { "path": "dist/assets/*.js", "limit": "150 KB" }
]

Verify any change with the build listing + grep for dropped exports; mid-range phones parse JS at ~1 MB/s, so every 50 KB is ~50 ms of main thread.

Continue Learning

Before You Start

Related Reading