Vite 04 ⚡ The Dev Server — Native ESM & on-demand module serving
The Vite dev server is a static file server with a transform pipeline attached. When the browser requests a module, Vite intercepts the request, transforms the file into browser-executable JavaScript, and returns it. The browser then parses the returned code, discovers the imports, and requests those modules in turn. The module graph is traversed by the browser at runtime, not by the build tool at startup . The previous chapter covered the project structure; this chapter covers the request lifecycle that makes the dev server work.
The server does not bundle the source code. It serves the transformed modules one at a time. The index.html requests src/main.tsx, and the browser follows the imports from there. A module that is never imported is never transformed, and a route that the user never visits is never loaded . The result is a dev server that starts in milliseconds regardless of the application size, because the startup cost is independent of the module count.
Key point: The dev server does not process the entire application upfront. It transforms each module on demand, as the browser requests it. The dependency pre-bundling is the exception: the dependencies are pre-bundled once at startup, because they rarely change and because the pre-bundling prevents the browser from firing hundreds of requests for a library’s internal modules .
Why the dev server requires the transform pipeline
The browser-transform problem.
The browser cannot execute JSX, TypeScript, Vue SFCs, or CSS imports natively. When the browser requests App.tsx, Vite must transform the file into plain JavaScript before returning it. The transform is per-file and on-demand: Vite does not transform the whole project, only the file that was requested . The esbuild is the default transformer for the TypeScript and the JSX, and it is fast enough that the per-file transform is imperceptible.
The bare-import problem.
The browser understands relative paths (./App.tsx) and absolute paths (/src/main.tsx), but it does not understand the bare specifiers like import { createApp } from 'vue'. The browser does not know that vue should be resolved from node_modules. Vite rewrites the bare specifier to the pre-bundled path, such as /node_modules/.vite/deps/vue.js?v=abc123, and the browser requests that URL instead . The rewrite is the mechanism that connects the source code to the pre-bundled dependencies.
The HTTP-caching problem.
The pre-bundled dependencies are served with the Cache-Control: max-age=31536000,immutable header, which tells the browser to cache them indefinitely. The source modules are served with the 304 Not Modified conditional requests, so the browser revalidates them on every reload but does not re-download the unchanged files . The caching headers are the mechanism that keeps the reload fast without re-fetching the entire module graph.
a. The request lifecycle
The lifecycle starts with the index.html and follows the browser’s requests.
<!-- index.html -->
<script type="module" src="/src/main.tsx"></script>
The type="module" tells the browser to treat the script as an ES module. The browser requests /src/main.tsx, and Vite receives the request .
// src/main.tsx
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
Vite transforms the main.tsx into JavaScript. The react-dom/client is a bare specifier, so Vite rewrites it to the pre-bundled path. The ./App is a relative import, so Vite leaves it as a relative path, and the browser requests /src/App.tsx. The ./index.css is a CSS import, and Vite transforms it into a JavaScript module that injects the styles .
The browser requests /src/App.tsx, and Vite transforms it in turn. The cycle continues until the module graph is exhausted.
b. The dependency pre-bundling
The pre-bundling is the exception to the on-demand rule. The dependencies are pre-bundled once at startup, because the dependencies rarely change and because the pre-bundling solves two problems.
# The pre-bundled dependencies
ls node_modules/.vite/deps/
# react.js, react-dom_client.js, vue.js, etc.
The first problem is the module format. Many dependencies ship as CommonJS or UMD, which the browser cannot import. The esbuild converts them into ESM . The second problem is the request flood. A library like lodash-es has over 600 internal modules, and the browser would fire 600+ HTTP requests if the library were served unbundled. The pre-bundling merges the internal modules into a single file, so the browser makes one request .
The pre-bundled files are cached in node_modules/.vite/deps with a content hash in the URL. The cache is invalidated when the lockfile, the dependency versions, the optimizeDeps configuration, or the Vite version changes .
c. The HMR and the module graph
The HMR uses the module graph that Vite has built during the request lifecycle. When a file changes, Vite identifies the module and its nearest HMR boundary, and sends the update over a WebSocket connection to the browser.
// The HMR update flow
// 1. The file changes on disk
// 2. Vite detects the change
// 3. Vite finds the HMR boundary in the module graph
// 4. Vite sends the update over WebSocket
// 5. The browser replaces the module without a full reload
The HMR update does not rebuild the bundle. It re-transforms the changed module and sends it to the browser, which replaces the module in place . The application state is preserved, and the update latency is measured in milliseconds. The module graph is the mechanism that makes the precise update possible.
Complete Example Session
# ============================================
# PART 1: START THE DEV SERVER
# ============================================
npm run dev
# The server starts in milliseconds
# ============================================
# PART 2: REQUEST THE INDEX
# ============================================
curl http://localhost:5173/
# The index.html is returned
# ============================================
# PART 3: REQUEST THE ENTRY MODULE
# ============================================
curl http://localhost:5173/src/main.tsx
# The transformed main.tsx is returned
# ============================================
# PART 4: OBSERVE THE BARE-IMPORT REWRITE
# ============================================
# In the returned main.tsx, the "react-dom/client"
# import is rewritten to /node_modules/.vite/deps/...
# ============================================
# PART 5: OBSERVE THE PRE-BUNDLED DEPS
# ============================================
ls node_modules/.vite/deps/
# The pre-bundled dependencies
# ============================================
# PART 6: EDIT A FILE
# ============================================
# Edit src/App.tsx and save
# The browser updates the module without a full reload
# ============================================
# PART 7: CHECK THE CACHE HEADERS
# ============================================
curl -I http://localhost:5173/node_modules/.vite/deps/react.js
# The Cache-Control: max-age=31536000,immutable header
The seven parts demonstrate the dev server lifecycle: the startup, the index request, the entry module, the bare-import rewrite, the pre-bundled dependencies, the HMR update, and the cache headers.
Quick Reference
The Request Lifecycle
| Step | Action |
|---|---|
| 1 | The browser requests index.html |
| 2 | Vite returns the HTML with the module script |
| 3 | The browser requests /src/main.tsx |
| 4 | Vite transforms the file and rewrites the bare imports |
| 5 | The browser requests the imported modules |
| 6 | The cycle repeats until the graph is exhausted |
The Transform Pipeline
| File Type | Transform |
|---|---|
| TypeScript | esbuild strips the types |
| JSX | esbuild transforms the JSX |
| CSS | Transformed into a JS module |
| Vue SFC | The Vue plugin compiles the SFC |
| Bare imports | Rewritten to the pre-bundled paths |
The Caching
| Resource | Header |
|---|---|
| Pre-bundled deps | Cache-Control: max-age=31536000,immutable |
| Source modules | 304 Not Modified |
Best Practices
✅ Do This:
# Start the dev server with the default config
npm run dev # ✅
# Check the pre-bundled dependencies
ls node_modules/.vite/deps/ # ✅
# Use the optimizeDeps.include for the runtime-discovered deps
optimizeDeps: { include: ['echarts/core'] } # ✅
# Clear the cache with --force when the deps change
npm run dev -- --force # ✅
❌ Don’t Do This:
# Expect the dev server to bundle the source code
# It serves the modules on demand # ❌
# Ignore the pre-bundling cache
# The changed dependency needs a re-bundle # ❌
# Assume the HMR requires a full rebuild
# It sends the changed module only # ❌
# Ship the unbundled dev output to production
# The build command is the production path # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The dependency is not pre-bundled | The import is not discoverable | Add it to optimizeDeps.include |
| The local package change is not reflected | The package is pre-bundled and cached | Add it to optimizeDeps.exclude or use --force |
| The browser floods the server | A large dependency is not pre-bundled | Pre-bundle it |
| The HMR update is slow | The module chain is large | Expected; still faster than a rebuild |
| The cache does not invalidate | The dependency version changed | The lockfile change triggers the re-bundle |
Real-World Examples
1. Start the dev server
npm run dev
2. Check the pre-bundled deps
ls node_modules/.vite/deps/
3. Include a runtime dep
optimizeDeps: { include: ['echarts/core'] }
4. Exclude a local package
optimizeDeps: { exclude: ['@my/utils'] }
5. Force the re-bundle
npm run dev -- --force
6. Clear the cache
rm -rf node_modules/.vite
7. The transformed module
curl http://localhost:5173/src/main.tsx
8. The cache header
curl -I http://localhost:5173/node_modules/.vite/deps/react.js
9. The HMR update
// Edit a file, the browser updates the module
10. The index.html
<script type="module" src="/src/main.tsx"></script>
Visual
The Request Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ THE BROWSER DRIVES THE MODULE GRAPH │
│ │
│ Browser requests /index.html │
│ │ │
│ ▼ │
│ Vite returns the HTML │
│ │ │
│ ▼ │
│ Browser requests /src/main.tsx │
│ │ │
│ ▼ │
│ Vite transforms the file, rewrites the bare imports │
│ │ │
│ ▼ │
│ Browser requests the imports │
│ │ │
│ ▼ │
│ The cycle repeats │
└──────────────────────────────────────────────────────────────┘
The Bare-Import Rewrite
┌──────────────────────────────────────────────────────────────┐
│ VITE REWRITES THE BARE SPECIFIERS │
│ │
│ Source code: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ import { createApp } from 'vue' │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Transformed: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ import { createApp } from '/node_modules/.vite/deps/ │ │
│ │ vue.js?v=abc123' │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ The browser requests the pre-bundled file. │
└──────────────────────────────────────────────────────────────┘
The HMR Update
┌──────────────────────────────────────────────────────────────┐
│ THE HMR REPLACES THE MODULE, NOT THE BUNDLE │
│ │
│ File changes on disk │
│ │ │
│ ▼ │
│ Vite finds the HMR boundary │
│ │ │
│ ▼ │
│ Vite sends the update over WebSocket │
│ │ │
│ ▼ │
│ The browser replaces the module │
│ │ │
│ ▼ │
│ The application state is preserved │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Dev server | Static server with the transform pipeline |
| Source code | Transformed on demand |
| Dependencies | Pre-bundled once at startup |
| Bare imports | Rewritten to the pre-bundled paths |
| HMR | Module update over WebSocket |
| Pre-bundle cache | node_modules/.vite/deps |
| Pre-bundle cache header | max-age=31536000,immutable |
| Source cache header | 304 Not Modified |
| Production build | Bundled (Rolldown) |
Key takeaways:
- The dev server does not bundle the source code. It serves the modules on demand, and the browser requests each module it needs. The transform happens per file, as the request arrives .
- The bare imports are rewritten to the pre-bundled paths. The browser cannot resolve the
vueor thereactspecifiers, so Vite rewrites them to the/node_modules/.vite/deps/paths . - The dependencies are pre-bundled for the compatibility and the performance. The CommonJS and UMD dependencies are converted to ESM, and the ESM dependencies with many internal modules are merged into a single file .
- The HMR uses the module graph. When a file changes, Vite finds the HMR boundary and sends the update over WebSocket, without a full rebuild .
- The caching headers keep the reload fast. The pre-bundled dependencies are cached indefinitely, and the source modules are revalidated conditionally .
- The production build is still bundled. The unbundled ESM is inefficient over the network, so the production build uses the bundler to merge the static imports and split the dynamic imports .
Remember: The Vite dev server is the unbundled server. It transforms the modules on demand, and the browser drives the graph traversal. The dependencies are pre-bundled once, and the bare imports are rewritten to the pre-bundled paths. The HMR updates a single module over WebSocket. The result is the instant startup and the fast updates, which is the difference between the bundler-based dev server and the native ESM-based one.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!