Your app works perfectly with npm run dev. You deploy it, and it breaks. This is one of the most common and frustrating experiences in web development. It happens because development mode and production mode are fundamentally different, they optimize for different goals, use different tools under the hood, and behave differently in subtle ways. AI tools do not understand this distinction, which means they cannot reliably fix "works in dev, broken in prod" issues.
Two modes, two goals
Development mode optimizes for your experience as a developer: fast startup, readable code, helpful error messages. Production mode optimizes for your users: small files, fast downloads, no debug information leaked.
| Aspect | Development (npm run dev) | Production (npm run build) |
|---|---|---|
| Startup speed | Instant (no bundling) | Slower (full build pipeline) |
| File sizes | Large, unminified | Minified and compressed |
| Source maps | Inline (full detail) | Separate file or none |
| HMR | Yes, instant updates | Not applicable |
| Error messages | Detailed with stack traces | Minimal (for security) |
| Filenames | Original (App.tsx) | Hashed (App.a1b2c3.js) |
| Tree shaking | No | Yes, unused code removed |
| Code splitting | No | Yes, lazy-loaded chunks |
| Build tool | esbuild (fast, loose) | Rollup (thorough, strict) |
The last row is critical: Vite uses different tools for dev and prod. esbuild is fast but less thorough. Rollup is slower but catches more issues. This is why something can work in dev and fail in prod.
What happens during npm run dev
When you start the dev server, Vite does not bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together. your code. Instead, it intercepts browser requests and transforms files one at a time, on demand:
Browser requests /src/App.tsx
↓
Vite intercepts:
1. Strips TypeScript types
2. Converts JSX to createElement()
3. Rewrites import paths to node_modules
↓
Browser receives a valid ES moduleThis is why startup is instant, nothing is processed until the browser actually asks for it. But it also means you are running untested, unoptimized code. Some problems only surface after the full build pipelineWhat is pipeline?A sequence of automated steps (install, lint, test, build, deploy) that code passes through before reaching production. runs.
What happens during npm run build
A production build runs your code through a complete optimization pipelineWhat is pipeline?A sequence of automated steps (install, lint, test, build, deploy) that code passes through before reaching production.:
npm run build
# → dist/
# ├── index.html
# ├── assets/
# │ ├── index.a1b2c3.js (your app code, minified)
# │ ├── vendor.d4e5f6.js (third-party libraries)
# │ └── index.g7h8i9.css (all styles, minified)MinificationWhat is minification?Shrinking JavaScript and CSS files by removing whitespace, shortening variable names, and stripping comments so they download faster. compresses your code:
// Development: 189 bytes, readable
function calculateShippingCost(orderTotal, isExpress) {
const baseRate = 5.99;
if (isExpress) {
return baseRate * 2;
}
return orderTotal > 50 ? 0 : baseRate;
}
// Production: 58 bytes (69% smaller)
function s(t,e){const n=5.99;return e?2*n:t>50?0:n}Tree shakingWhat is tree shaking?A build tool feature that removes unused exported code from your final bundle, reducing the amount of JavaScript shipped to users. removes code you never use:
// You import one function from a utility library
import { debounce } from 'lodash-es';
// Tree shaking includes ONLY debounce's code in the bundle
// The other 300+ lodash functions are excluded entirelyCode splittingWhat is code splitting?Breaking your application into smaller JavaScript chunks that load on demand so users only download the code needed for the page they're viewing. breaks your app into chunks that load on demand:
// This route is lazy-loaded - its code downloads only when
// the user navigates to /settings
const Settings = React.lazy(() => import('./pages/Settings'));Asset hashingWhat is hashing?A one-way mathematical transformation that turns data (like a password) into a fixed-length string that can't be reversed. Used to store passwords securely. gives each output file a unique name based on its content (main.a1b2c3.js). When you deploy a new version, the hash changes, forcing browsers to download the fresh file instead of serving a stale cached copy.
Environment-specific code
Vite provides built-in flags to write mode-specific logic:
// Only runs in development
if (import.meta.env.DEV) {
console.log('Debug info:', state);
}
// Only runs in production
if (import.meta.env.PROD) {
analytics.init('prod-key-abc123');
errorTracking.enable();
}
// Vite removes dead branches at build time:
// In production, the DEV block is completely stripped from the bundleYou can also use different .env files per mode:
.env ← loaded in all modes
.env.development ← loaded only during npm run dev
.env.production ← loaded only during npm run buildconsole.log debugging, but those logs are stripped in production builds. Always specify which mode the bug occurs in when asking AI for help, and always test fixes with npm run build && npm run preview.The "works in dev, broken in prod" checklist
This pattern is common enough to deserve a systematic debugging approach:
| Problem category | Symptom | How to check |
|---|---|---|
| Missing env variables | API calls fail, features missing | console.log(import.meta.env) in dev vs preview |
| Case-sensitive paths | import './button' works on Mac, fails on Linux | Check exact filename capitalization |
| Hardcoded localhost | API calls to localhost:3001 fail in prod | Search for localhost in your codebase |
| Tree shaking side effects | A library's CSS or initialization code disappears | Check if the import has sideEffects in its package.json |
| Dynamic imports | import(variable) doesn't work in production | Use import('./known-path.js') with static strings |
| Missing polyfills | Feature works in Chrome but not Safari/Firefox | Check browser compatibility on caniuse.com |
Testing production builds locally
Before deploying, always test the production build on your machine. It takes 30 seconds and catches most "dev vs prod" issues:
# Step 1: Build the production bundle
npm run build
# Step 2: Serve it locally (exactly as a web server would)
npm run preview
# → http://localhost:4173
# Step 3: Test manually
# - Do all pages load?
# - Do API calls work?
# - Do images and styles appear?
# - Does routing work on refresh?npm run preview is not a development server, it serves your compiled dist/ folder exactly as a static web server would. There is no HMR, no on-demand transforms. What you see is what your users will see.
Quick reference
| Command | Mode | Output | Use when |
|---|---|---|---|
npm run dev | Development | Live server, HMR | Writing code |
npm run build | Production | dist/ folder | Preparing to deploy |
npm run preview | Production (local) | Serves dist/ | Testing before deploy |
// Mode-specific configuration in vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => ({
plugins: [react()],
build: {
sourcemap: mode === 'production' ? 'hidden' : true,
minify: mode === 'production' ? 'esbuild' : false,
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom']
}
}
}
},
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
}
}));