Vite · beginner
Vite build fails while dev works: compare production-only transforms
Vite builds can fail while `vite dev` succeeds because the production pipeline runs a different code path than the dev server. Dev uses on-demand native ESM with esbuild pre-bundling and runtime transforms, while production uses Rollup with stricter resolution, tree-shaking, minification, and CSS code-splitting. Understanding this asymmetry turns a confusing "works in dev, breaks in build" report into a tractable triage problem with named boundaries.
The symptoms
- •`vite build` exits non-zero or hangs while `vite dev` serves the same source tree without errors in the browser console.
- •Build errors reference Rollup-only constructs such as "Rollup failed to resolve import", unresolved aliases that dev resolves via the optimizeDeps cache, or CSS `@import` chains that dev fetches lazily.
- •Runtime works in dev but the production bundle throws "X is not a function" or "Cannot read properties of undefined" at first interaction, often tied to minification or dead-code elimination removing a side effect.
Likely causes
- •Missing or mis-scoped `resolve.alias` entries that dev satisfies through esbuild pre-bundling of `node_modules` but Rollup resolves strictly, so a package subpath import fails only at build time.
- •`optimizeDeps` exclude/include lists that hide a CommonJS interop mismatch in dev but expose it under Rollup's stricter ESM resolver and tree-shaker, producing "named export not found" errors.
- •Production-only `define` substitutions (for example `process.env.NODE_ENV`) that collapse branching differently than dev's runtime evaluation, removing code paths the bundle still references.
- •CSS handling differences: dev loads `@import` and PostCSS at runtime through the browser fetch chain, while production parses and code-splits CSS through PostCSS plugins whose options differ from the dev pipeline.
- •Conditional dynamic imports wrapped in `if (import.meta.env.DEV)` blocks or environment-gated code that compiles out cleanly in production but never executes in dev, so its resolution path is untested.
First ten minutes
- 01Reproduce locally with a clean install: delete `node_modules` and the Vite cache directory, reinstall, then run `vite build --logLevel info` from the project root and capture the first error frame including file path and line.
- 02Diff the dev and build configurations: read `vite.config.*` and list every option that changes behavior between modes, paying attention to `resolve.alias`, `optimizeDeps`, `define`, `build.rollupOptions`, and `css.postcss`.
- 03Pinpoint the failing module: locate the file path in the Rollup error and run `vite build` with `--debug` or set `build.minify: false` and `build.sourcemap: true` to obtain a usable stack and source-mapped identifier.
- 04Compare resolution: with dev temporarily disabled, ask whether the failing import resolves under `node --experimental-vm-modules` or a minimal Rollup config outside Vite; if it does, the cause is a Vite-specific option, not your source.
- 05Triage environment boundaries: confirm `import.meta.env.MODE` and `import.meta.env.PROD` resolve to the values you expect at build time, since production-only `define` substitutions happen before Rollup parses your module graph.
Evidence to collect
- •Full stdout and stderr from `vite build`, including any "transformed in N ms" lines, the resolved file path, the unresolved import specifier, and the Rollup error code (for example `UNRESOLVED_IMPORT`, `MISSING_EXPORT`).
- •The contents of `vite.config.*` with comments marking which options apply to dev-only, build-only, or shared pipelines.
- •The dependency manifest: `package.json` `dependencies`, `devDependencies`, `peerDependencies`, and `overrides`; record the resolved versions reported by the package manager lockfile.
- •The relevant source snippet around the failing import, including any `/* @vite-ignore */`, `?url`, `?raw`, or `?worker` query suffixes that change dev versus build semantics.
- •The diff between `import.meta.env.DEV` and `import.meta.env.PROD` branches at the failing site, since these are statically replaced at build time.
Where to look
- •The boundary between `optimizeDeps` (esbuild-driven dev pre-bundle) and `build.rollupOptions` (Rollup-driven production bundle), because they use different resolvers and interop strategies.
- •The boundary between `define` and `import.meta.env.*`, because both substitute values at build time and can silently change code shape compared to dev runtime.
- •The boundary between runtime CSS loading in dev (fetch-based `@import`) and build-time CSS extraction (PostCSS plugins, CSS code-splitting, asset filename hashing).
- •The boundary between module specifiers that resolve through Node's `exports`/`imports` fields versus package `main`/`module`, since Rollup is stricter than esbuild about honoring the `exports` map.
Diagnostic steps
- 01Identify the error category from the Rollup code in the build output: `UNRESOLVED_IMPORT` points to a resolver/alias gap; `MISSING_EXPORT` points to interop or named-export mismatches; a CSS error points to PostCSS or `@import` semantics; a transform error points to a plugin ordering issue.
- 02Test resolution in isolation by adding the failing specifier to `resolve.alias` with an explicit absolute path under your `node_modules` and re-running `vite build`; success confirms a resolution gap, failure points to an interop or define issue.
- 03Temporarily set `build.minify: 'esbuild'` then `'terser'` to see whether the failure is in transformation, parsing, or minification; Rollup parse errors occur before minification, so isolating the stage narrows the cause.
- 04Disable tree-shaking side-effect handling with `build.rollupOptions.treeshake: 'safest'` and observe whether the error disappears; if it does, a package's `sideEffects: false` claim is misleading and needs `optimizeDeps.include` or an explicit sideEffects override.
- 05Compare CSS pipeline by running `vite build` with `build.cssCodeSplit: false` and reading any PostCSS warnings; if the build then succeeds, the failure lies in CSS chunking or `@import` rewriting that dev never executes.
- 06Inspect environment substitution by logging `import.meta.env.MODE`, `import.meta.env.PROD`, and any custom `define` keys at module top level; mismatches here explain production-only "undefined" symptoms that dev never reproduces.
Common mistakes
- •Assuming `vite dev` proves the module graph is valid: dev uses esbuild pre-bundling and browser-native ESM, so it can mask resolution paths Rollup refuses and side effects Rollup eliminates.
- •Adding `/* @vite-ignore */` to silence a build error without understanding why: this hides the symptom but leaves the bundle missing the asset or module, producing a runtime failure rather than a build failure.
- •Editing `optimizeDeps.exclude` to "fix" an interop mismatch seen only in dev: this often shifts the failure into the production bundle because Rollup now resolves the previously-pre-bundled package on its own.
- •Treating a `process.env.NODE_ENV` reference as a runtime concern when it is actually substituted at build time, so a missing or overly aggressive `define` entry removes code that dev still executes.
- •Relying on `package.json` `main`/`module` fields when the package declares `exports`: Rollup honors the `exports` map strictly, so a subpath import that worked in dev can be rejected in build.
Safe fixes
- •Conditional on an `UNRESOLVED_IMPORT` error pointing at a `node_modules` subpath: add a precise `resolve.alias` entry mapping the package subpath to its absolute file under your lockfile-resolved path, then re-run `vite build` and confirm the error frame disappears while the dev server still serves unchanged.
- •Conditional on a `MISSING_EXPORT` error from a CommonJS dependency: add the package name to `optimizeDeps.include` so esbuild produces an ESM-compatible pre-bundle that exposes the named export Rollup expects, then verify the build emits no interop warning for that module.
- •Conditional on a production-only "undefined" symptom caused by `define` over-substitution: tighten the `define` keys to match only the exact identifiers your source uses, and replace any loose `process.env` references with `import.meta.env.*` so dev and build share one substitution path.
- •Conditional on a CSS-only build error in `@import` or PostCSS: align the PostCSS configuration between dev and build by reading the loaded config from `vite build --debug` output and ensuring the same plugins run in both modes, then re-run `vite build` with `build.cssCodeSplit: true`.
- •Conditional on tree-shaking removing a needed side effect: set `build.rollupOptions.treeshake.moduleSideEffects: 'keep'` for the specific affected module pattern, or correct the upstream `sideEffects` field and pin the version; verify the previously-missing identifier now appears in the final chunk.
Prove the fix
- 01`vite build` exits with code 0 and emits the expected `dist/index.html` plus the chunk and asset manifests, with no Rollup `UNRESOLVED_IMPORT` or `MISSING_EXPORT` errors in the captured log.
- 02Inspection of `dist/assets/*.js` with a source-mapped viewer shows the previously-missing identifier or import target present in the chunk that the failing route loads; the corresponding source map line resolves to the intended source file.
- 03A production-style smoke test loads the built bundle through a static server on an explicit port and exercises the previously-failing interaction, confirming no `TypeError` or missing-module error appears in the browser console.
- 04A regression check that compares `vite dev` and `vite build` for the same source revision produces no new errors on the previously-failing path, and the lockfile-pinned dependency versions are unchanged across the fix.
Prevention and next steps
- •Treat `vite build` as a first-class CI gate alongside `vite dev`: a build-only smoke step that fails on non-zero exit catches production-only regressions before they reach a release branch.
- •Keep dev and build option sets aligned by writing a small config-audit script that diffs effective values of `resolve.alias`, `optimizeDeps`, `define`, and PostCSS plugins between modes, and surface mismatches in CI.
- •Prefer `import.meta.env.*` over `process.env.*` for build-time branching, and document every `define` key so reviewers can verify the substitution is consistent across dev and production.
- •Pin dependency versions and review `package.json` `exports`/`sideEffects` fields when upgrading, since Rollup honors these strictly and a minor upstream change can shift production-only behavior.
Safe commands and checks
Run the build in an isolated temporary checkout using the lockfile-resolved dependencies, then execute npx vite build --logLevel info.
npx vite build --debug 2>&1 | tee <build-debug-log>
npx vite build --mode production --logLevel info
node -e "console.log(require.resolve('<package-subpath>', { paths: [process.cwd()] }))"
grep -nE "UNRESOLVED_IMPORT|MISSING_EXPORT|CIRCULAR" <build-debug-log>
npx vite build --config <vite-config-path> --logLevel warn