Skip to content

Vite Dev Server and Production Builds

intermediate18 min read

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.

Mental Model

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:

  1. Browser requests /src/main.js
  2. Browser parses the imports, requests /src/App.vue
  3. Vite intercepts, transforms the .vue file on the fly, returns JavaScript
  4. 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.

Quiz
Why does Vite's dev server start faster than webpack's, even for large applications?

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
Quiz
You install a new npm package that publishes CommonJS (module.exports). You import it in your Vite project. What happens?

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:

Execution Trace
File saved
Vite detects src/components/Button.tsx changed
chokidar filesystem watcher
Transform
Vite transforms only the changed file
SWC/esbuild transforms TypeScript + JSX — takes 1-5ms
Invalidate
Vite determines the HMR boundary
Walks up the module graph until it finds a module that accepts HMR updates
Notify
Sends WebSocket message with the module URL
Only the URL of the invalidated module, not the code itself
Fetch
Browser fetches the updated module via HTTP
Appends a timestamp query param to bust the browser cache
Apply
HMR runtime re-executes the module and its accept handler
React Fast Refresh preserves component state

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?

  1. Network overhead — hundreds of unbundled ESM requests would be slow on real networks, even with HTTP/2
  2. Tree shaking — you need a bundler to eliminate dead code across the module graph
  3. Code splitting — intelligent chunk splitting requires whole-graph analysis
  4. Minification — you want the smallest possible output
  5. 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'],
        },
      },
    },
  },
})
Quiz
Why does Vite use a different tool (Rollup) for production builds instead of its native ESM approach from dev?

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

AspectWebpackVite
Cold start (large app)15-60 seconds (bundles everything)300ms-2s (pre-bundles deps only)
HMR speed1-10 seconds (depends on graph size)Under 50ms (constant, regardless of size)
Config complexityHigh — loaders, plugins, resolve, optimizationLow — sensible defaults, minimal config needed
Production bundlerWebpack itselfRollup (more optimized output)
Dev/prod paritySame tool — consistent behaviorDifferent tools — occasional dev/prod differences
Plugin ecosystemMassive, matureGrowing rapidly, Rollup-compatible
CSS handlingRequires css-loader + style-loader + configBuilt-in — just import .css files
TypeScriptRequires ts-loader or babel-loaderBuilt-in via esbuild (type-strip, no type checking)
Legacy browser supportExcellent with Babel/core-jsVia @vitejs/plugin-legacy
Common Trap

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 doWhat 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
Key Rules
  1. 1Vite serves source files as native ESM in dev — no bundling step, so startup is nearly instant regardless of app size.
  2. 2Dependencies are pre-bundled with esbuild on first run — converting CJS to ESM and collapsing many-file packages into single files.
  3. 3HMR works by sending module URLs over WebSocket, not code. The browser fetches the update via HTTP. Speed is constant regardless of app size.
  4. 4Production builds use Rollup for bundling, tree shaking, code splitting, and minification — native ESM is not suitable for production.
  5. 5Vite's plugin API is a superset of Rollup's — most Rollup plugins work in Vite with zero changes.
  6. 6esbuild strips TypeScript types but doesn't type-check — always run tsc separately.