Vite Architecture
A practical walkthrough of how Vite works internally, verified against the source code in _repos/vitejs-vite/.
Package Structure
Vite is a monorepo with three packages under packages/:
| Package | Purpose |
|---|---|
vite | The core - dev server, build pipeline, optimizer, plugin system |
create-vite | npm create vite scaffolding CLI |
plugin-legacy | Generates legacy bundles for older browsers that lack native ESM support |
The core package (packages/vite/src/node/) is organized around five key areas:
src/node/
index.ts # Public API exports (createServer, build, defineConfig, etc.)
config.ts # Config resolution - merges user config, plugins, defaults
plugin.ts # Plugin interface extending Rolldown's plugin type
build.ts # Production build via Rolldown
server/ # Dev server (middlewares, module graph, HMR, transforms)
optimizer/ # Dependency pre-bundling (scan + bundle)
plugins/ # ~30 built-in plugins (CSS, resolve, assets, define, etc.)
ssr/ # SSR module loading and transforms
The main exports in index.ts show the public surface:
export { defineConfig, loadConfigFromFile, resolveConfig } from './config'
export { createServer } from './server'
export { build, createBuilder } from './build'
export { optimizeDeps } from './optimizer'
Two Modes: Dev vs Build
Vite has a fundamental architectural split. Dev and build use different strategies to serve modules.
Dev Mode: Unbundled Native ESM
Browser Vite Dev Server
------- ---------------
GET /src/main.ts ------> transformMiddleware
|
v
resolveId -> load -> transform
|
v
Return transformed ESM
<------ (import paths rewritten)
GET /node_modules/ Serve from optimizer cache
.vite/deps/react.js (pre-bundled ESM)
The browser’s native import statements drive loading. Vite only transforms the file the browser asks for, on demand. Node modules are pre-bundled into single ESM files by the optimizer so the browser does not have to make hundreds of requests for a package like lodash-es.
Build Mode: Bundled Output via Rolldown
Source Files
|
v
Rolldown (bundler)
|
+-- resolveId / load / transform (same plugin hooks)
|
v
Optimized Chunks
|
v
dist/ directory
Build calls rolldown() to produce optimized, code-split bundles. The same user plugins run in both modes because Vite’s plugin interface extends Rolldown’s. In build.ts:
export async function build(inlineConfig: InlineConfig = {}) {
const builder = await createBuilder(inlineConfig, true)
const environment = Object.values(builder.environments)[0]
return builder.build(environment)
}
The resolveRolldownOptions function (build.ts line 565) assembles the full Rolldown config, injecting Vite’s plugin chain and setting defaults for input, output, and transforms.
Dev Server Pipeline
Middleware Stack
When createServer() is called (server/index.ts line 470), it builds a Connect middleware stack in this order:
1. timeMiddleware (DEBUG only, request timing)
2. rejectInvalidRequestMiddleware
3. rejectNoCorsRequestMiddleware
4. corsMiddleware (if cors enabled)
5. hostValidationMiddleware (DNS rebinding protection)
6. configureServer hooks (user plugin middlewares)
7. cachedTransformMiddleware (304 Not Modified fast path via etag)
8. proxyMiddleware (if proxy configured)
9. baseMiddleware (if base !== '/')
10. launchEditorMiddleware (__open-in-editor)
11. viteHMRPingMiddleware (responds to HMR pings)
12. servePublicMiddleware (static files from public/)
13. transformMiddleware (the main transform pipeline)
14. serveRawFsMiddleware (/@fs/ paths)
15. serveStaticMiddleware (other static files)
16. htmlFallbackMiddleware (SPA/MPA fallback)
17. configureServer post hooks (user plugin post-middlewares)
18. indexHtmlMiddleware (transform index.html)
19. notFoundMiddleware (404)
20. errorMiddleware (error overlay)
Request Flow Through transformMiddleware
When the browser requests a module (e.g., GET /src/App.tsx), the transformMiddleware (server/middlewares/transform.ts line 106) handles it:
- URL cleanup - Strips timestamps, decodes URI, checks if it is a JS/CSS/import request
- Delegates to
environment.transformRequest(url)
The transformRequest function (server/transformRequest.ts line 77) then:
- Deduplicates - If the same URL is already being processed, reuses that pending promise (unless the module was invalidated since the request started)
- Checks cache - Looks for
module.transformResulton theEnvironmentModuleNode - Runs
doTransformwhich callsloadAndTransform:pluginContainer.resolveId(url)- Resolve the URL to a file pathpluginContainer.load(id)- Load the file content (plugins or fallback tofs.readFile)pluginContainer.transform(code, id)- Run transform hooks (TypeScript, JSX, CSS modules, etc.)
- Caches the result on the module node as
transformResultwith an etag
Module Graph
The EnvironmentModuleGraph (server/moduleGraph.ts) tracks all modules and their relationships:
class EnvironmentModuleGraph {
urlToModuleMap: Map<string, EnvironmentModuleNode>
idToModuleMap: Map<string, EnvironmentModuleNode>
etagToModuleMap: Map<string, EnvironmentModuleNode>
fileToModulesMap: Map<string, Set<EnvironmentModuleNode>>
}
Each EnvironmentModuleNode holds:
url- The public URL path (e.g.,/src/App.tsx)id- Resolved filesystem path + queryimporters/importedModules- Import graph edgestransformResult- Cached transform outputacceptedHmrDeps- Which imports this module accepts for HMRisSelfAccepting- Whether the module accepts its own updates
There is one module graph per environment (client, ssr). A backward-compatible ModuleGraph wraps them for older APIs.
HMR (Hot Module Replacement)
When chokidar detects a file change, the watcher fires handleHMRUpdate (server/hmr.ts line 370):
- Config/env change? Restart the entire server
- Vite client code change? Full page reload
- Application code change:
- Find all
EnvironmentModuleNodes for the changed file - Call the
hotUpdateplugin hook so plugins can filter/customize - Call
updateModuleswhich runspropagateUpdate(line 756) to walk up the import chain - If it reaches a module that accepts the update via
import.meta.hot.accept(), record that as a boundary - If it reaches a module with no HMR boundary (dead end), trigger a full page reload
- Send an
{ type: 'update', updates: [...] }payload over WebSocket
- Find all
HMR propagation runs in parallel across all environments by default, controlled by server.hotUpdateEnvironments.
Plugin System
Extending Rolldown
Vite’s Plugin interface (plugin.ts) directly extends Rolldown’s RolldownPlugin:
export interface Plugin<A = any> extends RolldownPlugin<A> {
enforce?: 'pre' | 'post'
apply?: 'serve' | 'build' | ((config, env) => boolean)
config?: ...
configResolved?: ...
configureServer?: ...
transformIndexHtml?: ...
hotUpdate?: ...
// ... other Vite-specific hooks
}
The comment in the source explains the design: “A valid vite plugin is also a valid Rollup plugin. On the contrary, a Rollup plugin may or may NOT be a valid vite universal plugin, since some Rollup features do not make sense in an unbundled dev server context.”
Hook Lifecycle
Standard module processing hooks run in this order for each module:
resolveId(source, importer) -> Where is this module?
|
v
load(id) -> What are the raw contents?
|
v
transform(code, id) -> Transform the code (TypeScript, JSX, etc.)
Plugin Ordering
The resolvePlugins function (plugins/index.ts line 41) assembles the full plugin chain:
1. optimizedDepsPlugin (serve pre-bundled deps)
2. watchPackageDataPlugin (watch package.json changes)
3. preAliasPlugin (resolve bare imports before alias)
4. aliasPlugin (path aliases from config.resolve.alias)
5. ...enforce:'pre' user plugins
6. oxcResolvePlugin (module resolution)
7. htmlInlineProxyPlugin
8. cssPlugin
9. oxcPlugin (TypeScript/JSX transforms via OXC)
10. jsonPlugin (native)
11. wasmHelperPlugin
12. webWorkerPlugin
13. assetPlugin
14. ...normal user plugins
15. wasmFallbackPlugin
16. definePlugin (process.env, import.meta.env replacements)
17. cssPostPlugin
18. ...build plugins pre
19. dynamicImportVarsPlugin
20. importGlobPlugin
21. ...enforce:'post' user plugins
22. ...build plugins post
23. clientInjectionsPlugin (dev only)
24. cssAnalysisPlugin (dev only)
25. importAnalysisPlugin (dev only, rewrites import paths)
Within each tier, hooks can specify { order: 'pre' | 'post' } for finer control. The getSortedPluginsByHook function handles this sorting per-hook.
Vite-Specific Hooks
These hooks have no Rolldown equivalent:
| Hook | When | Purpose |
|---|---|---|
config | Before config is resolved | Modify user config |
configEnvironment | Per environment | Modify environment-specific config |
configResolved | After config is resolved | Read final config, store references |
configureServer | Server creation | Add custom middlewares, store server ref |
configurePreviewServer | Preview server creation | Same for vite preview |
transformIndexHtml | HTML requests | Inject tags, transform HTML |
hotUpdate | File change | Customize HMR behavior per environment |
handleHotUpdate | File change (legacy) | Older single-environment HMR hook |
Dep Pre-Bundling (Optimizer)
Why Pre-Bundle?
Two problems with serving bare node_modules via native ESM:
- Request waterfall - A package like
lodash-eshas hundreds of internal modules. Each triggers a separate HTTP request. Pre-bundling collapses them into a single file. - CommonJS compatibility - Many npm packages only ship CJS. Browsers cannot load CJS via
<script type="module">. Pre-bundling converts them to ESM.
How It Works
The optimizer lives in optimizer/ and runs in two phases:
Phase 1: Scan (optimizer/scan.ts)
Uses Rolldown’s scan() API (a fast parse-only mode) to crawl entry points and discover which dependencies are imported:
async function build() {
await scan({
input: entries,
logLevel: 'silent',
plugins, // includes rolldownScanPlugin that records deps
})
}
Entry points are determined by:
- Explicit
optimizeDeps.entriesconfig build.rollupOptions.input- Fallback: glob for
**/*.htmlfiles in the project root
The scan plugin intercepts resolveId calls. When it sees a bare import that resolves to node_modules, it records it in the deps map. The scan does not produce output files, only a dependency list.
Phase 2: Bundle (optimizer/index.ts)
prepareRolldownOptimizerRun (line 751) takes the discovered deps and bundles each into a single ESM file:
const bundle = await rolldown({
input: flatIdDeps, // { 'react': '/path/to/react/index.js', ... }
platform, // 'browser' or 'node'
transform: { target, define },
plugins, // rolldownDepPlugin + user plugins
})
await bundle.write({
format: 'esm',
dir: processingCacheDir,
sourcemap: true,
})
Key details:
- Dep IDs are flattened (slashes replaced) to avoid nested output directories
- Each dep becomes a single entry chunk in
node_modules/.vite/deps/ - The
rolldownDepPluginreads the entry source as a virtual file to maintain correct paths - Export data is analyzed to determine if CJS interop is needed (
needsInterop)
Caching Strategy
The optimizer uses a hash-based caching system (DepOptimizationMetadata):
interface DepOptimizationMetadata {
hash: string // Combined config + lockfile hash
lockfileHash: string // From package-lock.json / yarn.lock / pnpm-lock.yaml
configHash: string // From relevant vite config fields
browserHash: string // hash + discovered deps (invalidates browser cache)
optimized: Record<string, OptimizedDepInfo>
discovered: Record<string, OptimizedDepInfo>
}
On server start, loadCachedDepOptimizationMetadata checks if:
- The lockfile hash matches (dependencies have not changed)
- The config hash matches (vite config has not changed)
If both match, the cached pre-bundled deps are reused without re-scanning or re-bundling.
Runtime Discovery
The createDepsOptimizer function (optimizer/optimizer.ts) handles deps discovered after the initial scan. When importAnalysisPlugin encounters an unoptimized bare import during dev:
registerMissingImportis called, adding the dep to thediscoveredmap- A debounced re-optimization is scheduled (100ms delay to batch discoveries)
- The new dep gets a
processingpromise that theoptimizedDepsPluginawaits before serving - After re-bundling, a full page reload may be triggered if the dependency graph changed significantly
This is where contribution opportunity #21149 would add progress logging - the current code provides minimal feedback during the bundling phase of runOptimizeDeps, which can be slow for large dependency trees.
The Rolldown Transition
Vite’s architecture has historically used two different tools:
| Role | Old (Vite 5) | Current (Vite 8) |
|---|---|---|
| Dev transforms | esbuild | OXC (via Rolldown) |
| Dep pre-bundling | esbuild | Rolldown |
| Production build | Rollup | Rolldown |
| CSS minification | esbuild/lightningcss | lightningcss |
| JS minification | esbuild/terser | OXC/terser |
Vite 8.0.0 (released March 12, 2026) completed this transition. The codebase uses "rolldown": "1.0.0-rc.9":
-
Rolldown replaces Rollup for builds -
build.tsimportsrolldowndirectly. TherollupOptionsconfig key is deprecated in favor ofrolldownOptions, though both still work. -
Rolldown replaces esbuild for dep pre-bundling -
optimizer/index.tscallsrolldown()for the bundling phase.scan.tsusesscanfromrolldown/experimental. TheesbuildOptionsconfig is deprecated in favor ofrolldownOptions. -
OXC replaces esbuild for transforms - The
oxcPlugin(plugins/oxc.ts) handles TypeScript and JSX transforms.esbuildis still listed as a dependency but primarily for backward compatibility and edge cases. -
Plugin interface alignment - Vite’s
Plugintype now extendsRolldownPlugininstead of Rollup’s type. A compatibility layer (#types/internal/rollupTypeCompat) maintains backward compat.
What this means architecturally: having a single engine (Rolldown) for both dev and build means more consistent behavior. Bugs where something worked in dev but broke in prod (or vice versa) because esbuild and Rollup handled edge cases differently should become less common.
The experimental.bundledDev flag also hints at the future: a mode where even dev uses full bundling via Rolldown instead of the unbundled ESM approach, served from memory via memoryFilesMiddleware.
Your Projects Connection
electron-vite (markdown-task-planner)
electron-vite wraps Vite to build Electron apps. It creates three Vite configurations (main process, preload, renderer) and runs them through the build pipeline. You interact with:
- Config resolution (
config.ts) - electron-vite generates Vite configs for each Electron target. TheconfigEnvironmenthook and environment-specific options (environments.client,environments.ssr) are relevant here since Electron has multiple process types. - Build pipeline (
build.ts) - Production builds of your Electron app go through Rolldown. ThecreateBuilderAPI with its multi-environment support maps well to Electron’s main/renderer split. - Plugin system - Any Vite plugins in your electron-vite config go through the same plugin resolution and ordering described above.
Vitest (mysukari.com, ai-skills-sync)
Vitest reuses Vite’s infrastructure to run tests. It creates a Vite dev server internally. You interact with:
- Dev server and module graph - Vitest uses Vite’s transform pipeline to process test files. When you run tests, each test file goes through the same
resolveId -> load -> transformpipeline. The module graph tracks dependencies between test files and source files. - Dep pre-bundling - Vitest pre-bundles test dependencies the same way the dev server does. If a test import triggers runtime discovery, the optimizer re-runs.
- HMR in watch mode - When you edit a source file, Vitest uses the module graph to determine which tests are affected (which test files import the changed module, directly or transitively) and re-runs only those tests.
- SSR transforms - Vitest runs test code in a Node.js-like environment, so it uses the SSR module runner path rather than the browser client path. The
environments.ssrconfiguration and theDevEnvironmentfor SSR are what Vitest relies on.
In both projects, the plugin system is the primary extension point. Understanding the hook lifecycle and ordering helps when debugging transform issues or writing custom plugins.