How Vite Works: ES Modules, Dependency Pre-Bundling, and the Architecture Behind the Fastest Dev Server
A deep-dive into Vite's architecture covering native ES module serving, esbuild dependency pre-bundling, HMR propagation, the plugin pipeline, and production Rollup builds for senior engineers.
Every JavaScript developer has stared at a webpack cold start that takes 30 seconds before the first page loads. For small projects it is annoying. For large apps with hundreds of modules it becomes a genuine productivity problem. Vite solves this with a fundamentally different approach: skip the bundle in development entirely.
Understanding why Vite is fast requires understanding what webpack was actually doing and where that time went. This is not a “Vite is better” argument; it is an explanation of the architectural tradeoffs that make Vite fast in dev and why those same tradeoffs require a completely different tool for production.
The Problem with Traditional Bundlers in Dev Mode
Webpack’s mental model is: collect all your source files, resolve their dependencies, transform them (TypeScript, JSX, CSS modules), and concatenate everything into one or more bundles that the browser can load. This works well for production. In development it creates a fundamental tension.
When you edit a single file, webpack has to figure out what changed and update the bundle. Even with aggressive caching this means reading potentially thousands of module files, re-running transforms, and writing a new bundle to disk or memory. With source maps on top, the I/O overhead adds up fast.
The core issue is that webpack’s unit of work is the bundle. The browser gets one big JavaScript file, which means webpack has to do the work of assembling that file on every relevant change. Tree-shaking, code splitting, and incremental compilation help at the margins, but they do not change the fundamental cost structure.
What browsers can do natively now changes the equation.
Native ES Module Serving
Vite’s dev server does not bundle your source code. Instead it serves each file as its own ES module over HTTP, and lets the browser handle the module graph.
When you write:
// src/app.ts
import { Button } from './components/Button'
import { useAuth } from './hooks/useAuth'
Vite serves app.ts with its imports intact. The browser parses the ES module, sees the import statements, and fires HTTP requests for ./components/Button and ./hooks/useAuth. Each of those files gets served individually, potentially triggering more requests. The browser builds the module graph itself.
Vite transforms files on request. When the browser asks for Button.tsx, Vite runs it through the appropriate transforms (TypeScript compilation, JSX transform, CSS modules) and returns the transformed JavaScript. The result is cached in memory so subsequent requests for the same file are instant.
This is why cold starts are fast: Vite only processes files the browser actually requests. In a large app where the current route only uses 30% of the codebase, Vite only transforms that 30%. Webpack had to process everything upfront to build the complete bundle.
The tradeoff is the number of HTTP requests. A complex module graph can generate hundreds of requests, which is why native ES modules in dev are practical today but would have been painful in 2015 when HTTP/2 was not ubiquitous. Vite relies on HTTP/2 multiplexing to keep this manageable.
Dependency Pre-Bundling with esbuild
Not everything works as a native ES module out of the box. Consider this import:
import React from 'react'
React is a CommonJS module. The browser cannot execute require() calls, and React’s source is split across dozens of files with CJS require chains internally. If Vite tried to serve React as-is, the browser would need to handle a format it does not understand, with hundreds of file requests just to load one library.
Vite solves this with dependency pre-bundling using esbuild. During startup, Vite scans your source for bare imports (imports that do not start with ., .., or /), finds their entry points in node_modules, converts CJS to ESM, and bundles each dependency into a single ESM file. esbuild does this in Go, running 10-100x faster than a JavaScript bundler would.
The pre-bundled files land in node_modules/.vite/deps/:
node_modules/.vite/deps/
react.js
react.js.map
react-dom.js
react-dom.js.map
_metadata.json
The _metadata.json file records which packages were bundled and a hash of the inputs used to produce them. That hash covers your package-lock.json (or yarn.lock or pnpm-lock.yaml), vite.config.ts, and any optimizeDeps configuration you have set.
When Vite starts, it checks if the cache is still valid by comparing the current hash against what is stored in _metadata.json. If your lockfile changed (you installed a new package), the hash changes and Vite re-runs pre-bundling for the affected dependencies. If nothing changed, the cache is used directly and startup is near-instant.
You can inspect the cache invalidation logic yourself:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
// Force specific packages to be pre-bundled even if Vite
// does not detect them as bare imports (e.g., dynamic requires)
include: ['lodash-es', 'date-fns'],
// Prevent Vite from pre-bundling packages that are already ESM
// and do not need it
exclude: ['your-local-esm-lib'],
},
})
One subtlety: Vite pre-bundles dependencies lazily on first request too. If a dependency is discovered only when a particular route loads, Vite will pre-bundle it at that point and reload the page once. This can cause unexpected page reloads during development if you have many dynamic imports of heavy dependencies.
Hot Module Replacement: How the Module Graph Stays Fast
HMR is where Vite’s architecture pays off most clearly. Traditional bundlers have to figure out which parts of a bundle are affected by a change and produce an update. Vite’s module graph is already explicit: every file maps to its import relationships.
Vite maintains an in-memory module graph that tracks which modules import which. When a file changes, Vite:
- Marks the changed module as invalid in the graph.
- Walks up the import chain from the changed module toward the root.
- Finds the nearest HMR boundary, a module that has registered an
import.meta.hot.accept()handler. - Sends a WebSocket message to the client with the list of modules to update.
// A module can declare itself an HMR boundary
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// Replace the old module with the new one
// This callback receives the updated module
})
}
React Fast Refresh (injected by @vitejs/plugin-react) registers HMR boundaries at React component files automatically. When you edit Button.tsx, the boundary is found at Button.tsx itself, not at the root of your app. Vite sends a single module update, the browser re-executes only that file, and React swaps the component in place without losing state.
This is why Vite HMR speed does not degrade as your app grows. The traversal walks up the import graph until it hits a boundary, and the number of steps depends on your component hierarchy depth, not your total app size. Contrast this with webpack’s approach of having to invalidate and rebuild portions of the bundle even when the logical change is small.
If Vite walks all the way to the root without finding an HMR boundary, it falls back to a full page reload. This happens with files like vite.config.ts, index.html, or modules that do not participate in the HMR protocol.
The Plugin Pipeline
Vite extends Rollup’s plugin interface and adds dev-server-specific hooks. If you know Rollup plugins, Vite plugins feel familiar. The core hook chain for module loading is:
resolveId(id, importer): map an import specifier to a file path. Return a virtual module ID ornullto let Vite handle it.load(id): given a resolved ID, return the module’s source code. Useful for virtual modules that do not correspond to real files.transform(code, id): receive source code, return transformed code. This is where TypeScript compilation, JSX transforms, and CSS preprocessing happen.
// vite.config.ts
import { defineConfig, Plugin } from 'vite'
function virtualModulePlugin(): Plugin {
const virtualModuleId = 'virtual:my-config'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'virtual-module',
resolveId(id) {
if (id === virtualModuleId) {
// Prefix with \0 to signal that this is a virtual module
// Vite and Rollup treat \0-prefixed IDs specially
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const config = ${JSON.stringify({ env: 'dev' })}`
}
},
}
}
export default defineConfig({
plugins: [virtualModulePlugin()],
})
Beyond the Rollup-compatible hooks, Vite adds dev-server-specific hooks:
configureServer(server): access the express-style dev server to add custom middleware. Useful for proxying requests or serving mock API endpoints during development.transformIndexHtml(html): transform the HTML entry point. Used to inject scripts, modify meta tags, or rewrite asset paths.handleHotUpdate({ file, server, modules }): intercept HMR events. Lets plugins decide which modules need to be invalidated or send custom HMR payloads.
The @vitejs/plugin-react plugin wires up @babel/plugin-transform-react-jsx for JSX, injects the React Fast Refresh runtime via transformIndexHtml, and registers handleHotUpdate to manage component-level HMR boundaries. This is why you get component-level hot reloading with state preservation out of the box.
// What @vitejs/plugin-react does conceptually
export default function reactPlugin(): Plugin {
return {
name: 'vite:react',
transform(code, id) {
if (!id.match(/\.[jt]sx?$/)) return
// Runs Babel with @babel/plugin-transform-react-jsx
// and react-refresh/babel for HMR support
return transformWithBabel(code, id)
},
transformIndexHtml() {
return [
{
tag: 'script',
attrs: { type: 'module' },
children: `import RefreshRuntime from '/@react-refresh'
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type`,
injectTo: 'head',
},
]
},
}
}
Production Builds with Rollup
Vite uses Rollup for production builds, not esbuild. This is a deliberate architectural decision with real tradeoffs.
esbuild is fast but has gaps in its optimization capabilities. At the time Vite’s production build approach was designed, esbuild lacked advanced code splitting heuristics, CSS code splitting, and some tree-shaking edge cases that matter for production bundle size. Rollup’s JavaScript plugin ecosystem is also much richer.
The production build pipeline looks like this:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
// Target browsers. Vite uses esbuild to transpile syntax
// and injects polyfills based on this setting.
target: 'es2020',
// Control manual chunk splitting
rollupOptions: {
output: {
manualChunks: (id) => {
// Keep React ecosystem in a single vendor chunk
if (id.includes('node_modules/react') ||
id.includes('node_modules/react-dom')) {
return 'react-vendor'
}
// Separate large utility libraries
if (id.includes('node_modules/lodash-es')) {
return 'utils'
}
},
},
},
},
})
Dynamic imports become automatic code split points. If you write:
const { HeavyChart } = await import('./components/HeavyChart')
Rollup sees the dynamic import and emits HeavyChart as a separate chunk. The browser only downloads it when the import is executed, keeping your initial bundle small.
CSS code splitting follows the same logic. CSS imported by a dynamically-loaded chunk gets extracted into its own .css file and injected via a <link> tag when the chunk loads. This prevents unused CSS from blocking initial render.
Asset hashing is handled automatically. Every output file gets a content hash in its filename (Button-BaF5Cd2A.js). Because the hash changes only when the content changes, CDN and browser caches can use aggressive max-age headers without serving stale files.
Tradeoffs Compared
| Dimension | Vite | webpack | Turbopack | esbuild |
|---|---|---|---|---|
| Dev startup time | Near-instant (no bundle) | 10-60s for large apps | Fast (Rust, incremental) | Near-instant |
| HMR speed | Very fast, app-size-independent | Degrades with app size | Fast, incremental | No HMR built-in |
| Production optimization | Good (Rollup) | Excellent (mature) | Improving | Limited (no advanced splitting) |
| Plugin ecosystem | Large (Rollup-compatible) | Largest | Early, growing | Minimal |
| Configuration complexity | Low | High | Low | Very low |
| Tree-shaking | Good | Good | Good | Good |
| CSS handling | First-class | Good with loaders | First-class | Limited |
| Monorepo support | Good with workspaces | Good | Good | Basic |
| Legacy browser support | Via @vitejs/plugin-legacy | Excellent | Not primary focus | Manual |
| SSR support | Built-in, dual build | Mature | Limited | N/A |
Production Considerations
Browser target configuration. The build.target option controls which JavaScript syntax features Vite will leave as-is versus transpile down. Setting target: 'es2015' forces Vite to transform arrow functions, classes, and template literals, which adds bundle size. For most SaaS apps targeting modern browsers, es2020 or esnext is correct and keeps output clean.
If you genuinely need IE11 or older Android WebView support, use @vitejs/plugin-legacy. It produces a second bundle with legacy syntax and polyfills, served via the module/nomodule pattern. Be aware this roughly doubles your build time and output size.
Chunk size analysis. Before shipping to production, run rollup-plugin-visualizer to see where your bundle size is coming from:
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
visualizer({
filename: 'dist/stats.html',
open: true,
gzipSize: true,
}),
],
})
The output is an interactive treemap showing each chunk’s contribution. Common surprises: locale data from date-fns or moment, full icon libraries when only a few icons are used, and polyfill packages that got bundled twice through transitive dependencies.
SSR dual builds. Vite’s SSR mode requires two builds: one for the browser and one for Node (or your edge runtime). The SSR build uses ssr: true in build options and produces CommonJS or ESM output appropriate for the server environment. The key gotcha is that externals behave differently: in the browser build everything gets bundled, but in the SSR build you typically want node_modules to remain as require() calls rather than being bundled into the server artifact.
// Build the SSR server entry
// vite build --ssr src/entry-server.ts
export default defineConfig({
build: {
ssr: true,
rollupOptions: {
input: 'src/entry-server.ts',
},
},
})
When Vite is not the right choice. Three situations where you should think carefully before adopting Vite:
First, if you have a large webpack config that took years to develop, with custom loaders, module federation setup, and dozens of custom plugins, the migration cost may outweigh the DX benefit. Webpack’s ecosystem of loaders is larger than Vite’s, and some niche webpack plugins do not have Vite equivalents.
Second, if your app genuinely needs IE11 support as a hard requirement with a large user base, webpack’s legacy bundle approach is better tested. @vitejs/plugin-legacy works, but production incidents with edge cases in the polyfill layer are more common than with webpack’s mature legacy handling.
Third, if your monorepo has complex cross-package dependency graphs with circular dependencies or unconventional module resolution strategies, Vite’s resolver can behave unexpectedly. webpack’s resolver is more permissive and has had more edge cases ironed out over the years.
Vite’s architecture is a good case study in choosing the right tool per context. Native ES modules in dev avoids the bundle-on-every-change problem. esbuild pre-bundling handles the CJS compatibility layer without slowing down the dev server. Rollup handles production where optimization quality matters more than build speed. The design is coherent rather than trying to be one tool that does everything.
If you are starting a new project or can afford a clean migration, the DX improvements are substantial. If you are in the middle of a production system with complex requirements, the tradeoffs in the table above are where to focus your evaluation.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.