Inside Vite's Dev Server: Middleware Stack, Transform Pipeline, and Module Graph
Prerequisites
- ›Articles 1-2: Architecture, configuration, and plugin system
- ›Node.js HTTP server and middleware pattern (connect/express)
- ›Rollup plugin hook concepts (resolveId, load, transform)
Inside Vite's Dev Server: Middleware Stack, Transform Pipeline, and Module Graph
When you run vite dev, a dev server starts that does something unusual: instead of bundling your source code, it serves each module individually as native ESM, transforming files on-demand as the browser requests them. This unbundled approach is what makes Vite's dev server fast — but it requires sophisticated infrastructure to handle import rewriting, caching, HMR invalidation, and dependency pre-bundling. Let's trace how all of it works, starting from server creation.
Server Creation and Environment Initialization
The createServer() function is just a thin wrapper around _createServer(), which begins on line 476. The creation process follows this sequence:
sequenceDiagram
participant CLI as CLI / User Code
participant CS as _createServer()
participant Config as resolveConfig()
participant HTTP as HTTP Server
participant WS as WebSocket Server
participant Env as DevEnvironment (per-env)
participant MW as Middleware Stack
CLI->>CS: createServer(inlineConfig)
CS->>Config: resolveConfig(inlineConfig, 'serve')
Config-->>CS: ResolvedConfig
CS->>HTTP: resolveHttpServer(middlewares, https)
CS->>WS: createWebSocketServer(httpServer, config)
loop For each environment in config.environments
CS->>Env: createEnvironment(name, config, { ws })
Env->>Env: init({ watcher })
Note over Env: Creates moduleGraph,<br/>pluginContainer,<br/>depsOptimizer
end
CS->>MW: Assemble middleware stack
CS-->>CLI: ViteDevServer
The environment initialization loop on lines 562-579 creates each DevEnvironment using the factory function from its resolved config:
const environment = await environmentOptions.dev.createEnvironment(
name, config, { ws },
)
environments[name] = environment
await environment.init({ watcher, previousInstance })
Each DevEnvironment gets its own EnvironmentModuleGraph, EnvironmentPluginContainer, and optionally a DepsOptimizer. The ViteDevServer interface then provides unified access to all environments through server.environments.
The Middleware Stack
Vite uses connect as its middleware framework. The middleware stack is assembled in _createServer() between lines 920-1030, and the ordering is security-first:
flowchart TD
A["timeMiddleware (DEBUG only)"] --> B
B["rejectInvalidRequestMiddleware"] --> C
C["rejectNoCorsRequestMiddleware"] --> D
D["corsMiddleware"] --> E
E["hostValidationMiddleware"] --> F
F["configureServer pre-hooks"] --> G
G["cachedTransformMiddleware"] --> H
H["proxyMiddleware"] --> I
I["baseMiddleware"] --> J
J["launchEditorMiddleware"] --> K
K["viteHMRPingMiddleware"] --> L
L["servePublicMiddleware"] --> M
M["transformMiddleware ⭐"] --> N
N["serveRawFsMiddleware"] --> O
O["serveStaticMiddleware"] --> P
P["htmlFallbackMiddleware"] --> Q
Q["configureServer post-hooks"] --> R
R["indexHtmlMiddleware"] --> S
S["notFoundMiddleware"] --> T
T["errorMiddleware"]
style M fill:#f96,stroke:#333,stroke-width:2px
The security layer rejects malformed requests, blocks cross-origin requests without CORS headers, and validates the Host header to prevent DNS rebinding attacks. The cachedTransformMiddleware (line 957) provides ETag-based 304 responses before any transform work happens.
The star of the stack is transformMiddleware, which intercepts requests for JS, CSS, and import-query URLs, runs them through transformRequest(), and serves the result. It only handles the client environment — SSR transforms go through a different path (the module runner, covered in Part 5).
Tip: Plugin authors can inject middlewares via the
configureServerhook. Middlewares returned from the hook run before internal middlewares. Return a function from the hook to add post middlewares — they execute after the internal stack, useful for catch-all routes.
The Plugin Container
During dev, there's no actual Rollup/Rolldown bundle process. Instead, Vite emulates Rollup's plugin execution model through the plugin container — based on WMR's implementation as credited in pluginContainer.ts.
Each DevEnvironment gets its own EnvironmentPluginContainer instance, created during environment.init(). The container implements the same resolveId, load, and transform hook interfaces that Rolldown uses during builds, providing a PluginContext with:
this.environment— the currentDevEnvironmentthis.resolve()— inter-plugin resolutionthis.emitFile()— asset emissionthis.parse()— AST parsing via Rolldown's parser
classDiagram
class EnvironmentPluginContainer {
+resolveId(id, importer, options)
+load(id, options)
+transform(code, id, options)
+buildStart()
+close()
-plugins: Plugin[]
-environment: DevEnvironment
}
class DevEnvironment {
+pluginContainer: EnvironmentPluginContainer
+moduleGraph: EnvironmentModuleGraph
+transformRequest(url): TransformResult
}
DevEnvironment --> EnvironmentPluginContainer
This is the abstraction that makes Rollup/Rolldown plugins work during dev: the plugin container calls the same hooks in the same order as a real bundle process, just one module at a time.
transformRequest(): The Heart of Dev Mode
When the transform middleware catches a request for /src/App.tsx, it calls environment.transformRequest(url), which delegates to the transformRequest() function:
sequenceDiagram
participant MW as transformMiddleware
participant TR as transformRequest()
participant PC as pluginContainer
participant MG as moduleGraph
participant FS as File System
MW->>TR: transformRequest(url)
TR->>TR: Check pending requests (dedup)
TR->>PC: resolveId(url)
PC-->>TR: resolved id + metadata
TR->>MG: ensureEntryFromUrl(url)
TR->>PC: load(id)
alt Plugin provides code
PC-->>TR: code
else No plugin handles
TR->>FS: fs.readFile(id)
FS-->>TR: code
end
TR->>PC: transform(code, id)
PC-->>TR: transformed code + sourcemap
TR->>TR: Generate ETag
TR->>MG: Update transformResult
TR-->>MW: TransformResult { code, map, etag }
The function starts with request deduplication on lines 109-130. If two requests for the same URL arrive simultaneously, the second one waits for the first's result rather than doing duplicate work. But there's a subtlety: if the module was invalidated between when the first request started and the second arrived, the pending result is stale, so the second request aborts the first and starts fresh.
The TransformResult type captures everything needed to serve the response:
export interface TransformResult {
code: string
map: SourceMap | { mappings: '' } | null
etag?: string
deps?: string[]
dynamicDeps?: string[]
}
Import Analysis: Bridging Native ESM and Vite
After transform hooks run, the importAnalysisPlugin (a dev-only plugin, ~1100 lines) performs the critical work of rewriting imports for the browser. Consider this source code:
import React from 'react'
import './styles.css'
import { helper } from './utils'
The browser can't resolve bare imports like 'react' or understand CSS imports. The import analysis plugin transforms this to:
import React from '/node_modules/.vite/deps/react.js?v=abc123'
import './styles.css?import'
import { helper } from '/src/utils.ts?t=1234567890'
It does this by:
- Parsing imports with
es-module-lexer - Resolving each specifier through the plugin container's
resolveId - Rewriting bare imports to pre-bundled dependency paths in
.vite/deps/ - Appending timestamp queries for cache busting on file changes
- Injecting HMR boundary markers for modules that call
import.meta.hot.accept() - Handling
import.meta.envandimport.meta.globsubstitutions
flowchart LR
A["import React from 'react'"] -->|resolveId| B["/.vite/deps/react.js?v=abc"]
C["import './styles.css'"] -->|CSS detection| D["./styles.css?import&t=123"]
E["import { x } from './utils'"] -->|timestamp| F["./utils.ts?t=123"]
G["import.meta.hot.accept()"] -->|HMR injection| H["__vite__createHotContext(url)"]
Tip: If you ever wonder why a module is being fully reloaded instead of hot-updated, look at the import analysis plugin's HMR boundary detection. A module that doesn't call
import.meta.hot.accept()— directly or via a framework's HMR handling — forces propagation upward through its importers.
The Module Graph Data Structure
The EnvironmentModuleGraph is the data structure that tracks all module relationships within an environment. It maintains three lookup indices for fast access:
urlToModuleMap: Map<string, EnvironmentModuleNode> // URL → module
idToModuleMap: Map<string, EnvironmentModuleNode> // resolved ID → module
fileToModulesMap: Map<string, Set<EnvironmentModuleNode>> // file → modules
A single file can map to multiple modules (e.g., with different query strings), which is why fileToModulesMap stores a Set.
Each EnvironmentModuleNode tracks:
classDiagram
class EnvironmentModuleNode {
+url: string
+id: string | null
+file: string | null
+type: "js" | "css" | "asset"
+importers: Set~EnvironmentModuleNode~
+importedModules: Set~EnvironmentModuleNode~
+acceptedHmrDeps: Set~EnvironmentModuleNode~
+acceptedHmrExports: Set~string~ | null
+isSelfAccepting?: boolean
+transformResult: TransformResult | null
+lastHMRTimestamp: number
+lastInvalidationTimestamp: number
+invalidationState: TransformResult | "HARD_INVALIDATED" | undefined
}
class EnvironmentModuleGraph {
+urlToModuleMap: Map
+idToModuleMap: Map
+fileToModulesMap: Map
+etagToModuleMap: Map
+getModuleByUrl(url)
+getModulesByFile(file)
+invalidateModule(mod, ...)
+onFileChange(file)
}
EnvironmentModuleGraph --> EnvironmentModuleNode : manages
EnvironmentModuleNode --> EnvironmentModuleNode : importers / importedModules
The importers and importedModules sets form a bidirectional graph. When a file changes, Vite looks up its modules via fileToModulesMap, then walks importers upward to find HMR boundaries. The acceptedHmrDeps set records which imported modules a given module has agreed to accept updates from (via import.meta.hot.accept(deps, callback)).
The invalidationState field distinguishes between soft invalidation (only timestamps need updating — the transform result is still valid) and hard invalidation (the code changed and must be re-transformed). This optimization avoids unnecessary re-transforms within HMR chains.
For backward compatibility, Vite also provides a ModuleGraph (imported from mixedModuleGraph.ts) on server.moduleGraph that merges the client and SSR environment graphs. Access to this is gated behind a future deprecation warning — new code should use server.environments.client.moduleGraph directly.
What's Next
We've now traced a request from the browser through the middleware stack, into the transform pipeline, and watched the module graph being populated. But what happens when you save a file? In the next article, we'll follow the complete HMR cycle — from chokidar file detection through module graph traversal, WebSocket dispatch, and client-side module re-fetch — and then explore how dependency pre-bundling discovers, bundles, and serves npm packages.