Vite Dev Server and Production Builds
Why Vite Exists
Let me paint the picture. It's 2020. You're working on a large React app with webpack. You save a file. You wait. And wait. The dev server takes 8 seconds to reflect your change. On a cold start, it's 30+ seconds before you can see anything in the browser. Your creative flow is completely shattered.
Evan You (creator of Vue) had the same frustration. He noticed something: browsers now natively support ES modules. What if the dev server just... didn't bundle anything? What if it served your source files directly and let the browser do the module resolution?
That insight became Vite. And it changed how we think about development tooling.
Think of the traditional bundler dev server (webpack) like a translator who reads your entire book before letting anyone see a single page. Every edit means re-translating sections of the book. Vite is like publishing each page independently — when someone requests page 47, you translate just that page on the spot. The reader (browser) handles navigating between pages using ESM's native import system. Startup is instant because there's no upfront translation step.
The Native ESM Dev Server
Here's Vite's core trick: during development, it doesn't bundle your application code at all. Instead, it serves your source files as native ES modules over HTTP.
When you open your app in the browser, the browser sees something like:
// What the browser receives from Vite's dev server
import { createApp } from '/node_modules/.vite/deps/vue.js?v=abc123'
import App from '/src/App.vue?t=1234567890'
createApp(App).mount('#app')
The browser's native ESM loader handles the import chain:
- Browser requests
/src/main.js - Browser parses the imports, requests
/src/App.vue - Vite intercepts, transforms the
.vuefile on the fly, returns JavaScript - Browser continues resolving deeper imports
Traditional bundler (webpack):
Save file → Rebuild entire dependency subgraph → Send full bundle → Reload
[================== 2-10 seconds ==================]
Vite dev server:
Save file → Transform single file → HMR update via ESM
[== 50ms ==]
This is why Vite's cold start is nearly instant regardless of app size. A 10-file app and a 10,000-file app start at roughly the same speed — Vite only processes files the browser actually requests.
Dependency Pre-Bundling with esbuild
There's a wrinkle with the native ESM approach. Imagine you import lodash-es — that's 600+ ES modules. Without any optimization, the browser would fire 600+ individual HTTP requests on page load. That's a performance disaster, even with HTTP/2 multiplexing.
Vite solves this with dependency pre-bundling: the first time you run vite, it scans your source files for bare imports (like import React from 'react'), then uses esbuild to bundle each dependency into a single file.
Before pre-bundling:
import { debounce } from 'lodash-es'
→ Browser would request 600+ modules from lodash-es
After pre-bundling:
import { debounce } from '/node_modules/.vite/deps/lodash-es.js'
→ Single file, one HTTP request
Why esbuild? Because it's 10-100x faster than JavaScript-based tools. Pre-bundling lodash-es takes ~15ms with esbuild vs ~1-2 seconds with Rollup. Vite caches the result in node_modules/.vite/deps/ — it only re-runs when your dependencies change (detected via lockfile hash).
Key things pre-bundling handles:
- CommonJS to ESM conversion — many npm packages still publish CJS. esbuild converts them to ESM so the browser can import them
- Module consolidation — packages with many internal modules get collapsed into one file
- Bare specifier resolution — rewrites
import 'react'to an actual file path the browser can fetch
HMR Over Native ESM
Hot Module Replacement in Vite works differently from webpack's approach. Since modules are served individually via ESM, HMR can be extremely precise:
The critical difference: webpack HMR sends the updated module code over the WebSocket. Vite HMR sends just the module URL, and the browser fetches the update via a normal HTTP request. This keeps the WebSocket payload tiny and leverages HTTP caching.
Vite's HMR speed doesn't degrade with app size. Whether you have 100 or 10,000 modules, updating a single component takes the same ~50ms because only that one module needs to be transformed and fetched.
Production Builds with Rollup
Here's where Vite makes a pragmatic tradeoff. Native ESM is perfect for development, but for production you still want a bundled output. Why?
- Network overhead — hundreds of unbundled ESM requests would be slow on real networks, even with HTTP/2
- Tree shaking — you need a bundler to eliminate dead code across the module graph
- Code splitting — intelligent chunk splitting requires whole-graph analysis
- Minification — you want the smallest possible output
- CSS extraction — CSS needs to be extracted into separate files with proper chunk correlation
Vite uses Rollup for production builds because:
- Rollup produces the smallest, most optimized ESM output
- Rollup's tree shaking is more aggressive than webpack's
- Rollup's plugin ecosystem is mature and well-tested
- Vite's plugin API is a superset of Rollup's, so most Rollup plugins work in Vite
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
charts: ['recharts'],
},
},
},
},
})
Vite Plugin API
Vite's plugin API extends Rollup's plugin API with additional Vite-specific hooks. If you've written Rollup plugins, you already know most of it.
function myPlugin() {
return {
name: 'my-plugin',
// Rollup-compatible hooks (work in both dev and build)
resolveId(source) {
if (source === 'virtual:my-module') {
return source
}
},
load(id) {
if (id === 'virtual:my-module') {
return `export const msg = "Hello from virtual module"`
}
},
transform(code, id) {
if (id.endsWith('.custom')) {
return transformCustomFormat(code)
}
},
// Vite-specific hooks (dev server only)
configureServer(server) {
server.middlewares.use('/api', myApiMiddleware)
},
handleHotUpdate({ file, server }) {
if (file.endsWith('.md')) {
server.ws.send({ type: 'full-reload' })
return []
}
},
}
}
The Rollup compatibility means the Vite plugin ecosystem is massive — thousands of Rollup plugins work out of the box.
Vite vs Webpack DX Comparison
| Aspect | Webpack | Vite |
|---|---|---|
| Cold start (large app) | 15-60 seconds (bundles everything) | 300ms-2s (pre-bundles deps only) |
| HMR speed | 1-10 seconds (depends on graph size) | Under 50ms (constant, regardless of size) |
| Config complexity | High — loaders, plugins, resolve, optimization | Low — sensible defaults, minimal config needed |
| Production bundler | Webpack itself | Rollup (more optimized output) |
| Dev/prod parity | Same tool — consistent behavior | Different tools — occasional dev/prod differences |
| Plugin ecosystem | Massive, mature | Growing rapidly, Rollup-compatible |
| CSS handling | Requires css-loader + style-loader + config | Built-in — just import .css files |
| TypeScript | Requires ts-loader or babel-loader | Built-in via esbuild (type-strip, no type checking) |
| Legacy browser support | Excellent with Babel/core-js | Via @vitejs/plugin-legacy |
Vite uses esbuild for TypeScript transformation in dev, but esbuild only strips types — it does not type-check. You need a separate tsc --noEmit process (or your IDE) for type checking. This catches people off guard: code compiles fine in Vite but has type errors that only surface when running tsc. Always run type checking in CI.
| What developers do | What they should do |
|---|---|
| Expecting Vite to type-check TypeScript Vite uses esbuild for TS transformation, which strips types without checking them. Type errors only appear when you run the TypeScript compiler directly. | Run tsc --noEmit separately for type checking |
| Using CommonJS syntax (require, module.exports) in Vite project files Vite's dev server serves native ESM. CommonJS syntax in your source files won't work in the browser. Vite's pre-bundling only converts CJS in node_modules dependencies. | Use ESM syntax (import/export) everywhere |
| Assuming dev and production behave identically Dev uses native ESM + esbuild. Production uses Rollup bundling. Edge cases around module resolution, CSS ordering, and dynamic imports can behave differently. | Test production builds regularly with vite build and vite preview |
| Importing hundreds of files at once without code splitting In dev, each import becomes a separate HTTP request. Importing an entire icon library as individual ESM files can slow down dev page loads. In production, Rollup handles it via chunking, but dev suffers without code splitting. | Use dynamic import() for heavy or conditional modules |
- 1Vite serves source files as native ESM in dev — no bundling step, so startup is nearly instant regardless of app size.
- 2Dependencies are pre-bundled with esbuild on first run — converting CJS to ESM and collapsing many-file packages into single files.
- 3HMR works by sending module URLs over WebSocket, not code. The browser fetches the update via HTTP. Speed is constant regardless of app size.
- 4Production builds use Rollup for bundling, tree shaking, code splitting, and minification — native ESM is not suitable for production.
- 5Vite's plugin API is a superset of Rollup's — most Rollup plugins work in Vite with zero changes.
- 6esbuild strips TypeScript types but doesn't type-check — always run tsc separately.