Dev-Server Internals — HMR, Pre-Bundling, Dependency Resolution
TL;DR
The dev server is what you live in 9 hours a day; understanding it explains the difference between “1s reload” and “30s rebuild.” Key concepts: dependency pre-bundling (one-time conversion of node_modules to ESM so the browser can consume them), module resolution (how the dev server finds files matching an import), and HMR (Hot Module Replacement — swapping changed modules in the live page without losing state). Vite’s dev server is the modern reference; Webpack’s is the incumbent; both share the same conceptual moving parts.
Interview Q&A
Q: What does a dev server actually do?
A: Sits between your source files and the browser, transforming on demand:
- Watches your filesystem.
- Receives HTTP requests from the browser (
GET /src/App.tsx). - Runs your file through transformers (TS → JS, JSX → JS, CSS preprocessing, etc.).
- Returns the transformed file.
- On file change, notifies the browser via a WebSocket — browser refetches the changed file (and only that file, with HMR).
Vite does this with native ESM: each module is a separate request. Webpack bundles before serving, so a single bundle.js download.
Q: Dependency pre-bundling — what and why?
A: node_modules contains thousands of files, often in CJS format. The browser can’t consume CJS, and 50,000 individual ESM requests for transitive deps would melt the browser.
Vite’s solution: pre-bundle node_modules once with esbuild at server start:
- Convert CJS → ESM.
- Merge each top-level dep into a single file (
reactbecomes one ESM file instead of 50). - Cache to
node_modules/.vite/. - Subsequent server starts reuse the cache (until deps change).
First start: dependency pre-bundling... done (1.2s)
node_modules/.vite/
deps/
react.js
react-dom.js
@tanstack_react-query.js
...
Your source code still serves as individual ESM files; only node_modules is bundled.
Q: Webpack doesn’t pre-bundle — what does it do instead?
A: Webpack bundles your entire app + deps into chunks for dev. Every change re-bundles affected modules. With caching (filesystem cache, persistent cache), incremental rebuilds are fast — but the initial cold start is slower than Vite’s because there’s no “skip the bundle” mode.
Turbopack (Webpack’s Rust successor) does the same — bundle for dev — but with aggressive Rust-native caching that approaches Vite’s perceived speed.
Q: HMR — Hot Module Replacement, conceptually.
A: When a module changes:
- Build tool detects the change.
- Computes the HMR boundary — which module is the smallest replaceable unit (a “self-accepting” module, like a React component file).
- Sends an HMR update message to the browser via WebSocket.
- Browser receives, fetches the new module code.
- Runtime replaces the old module with the new — calls registered
disposecallbacks, runs the new module’s setup, propagates to importers. - State is preserved where possible (React Fast Refresh keeps component state across HMR).
If a module doesn’t have an HMR handler (and isn’t part of a known framework integration like React/Vue), HMR bubbles up to the importers until it finds one that can accept. Worst case: full page reload.
Q: How does React Fast Refresh interact with HMR?
A: Fast Refresh is a React-specific HMR strategy.
- A file exporting only React components is self-accepting (its HMR boundary is itself).
- On change: React re-renders the new component versions; state is preserved per component instance via React’s internal “this hook was here last render, restore its state” logic.
- A file exporting non-component things (utilities, constants) isn’t self-accepting → HMR bubbles up.
The rule of thumb that makes Fast Refresh work: one component per file, and don’t mix component exports with non-component exports in the same file.
Vite ships Fast Refresh via @vitejs/plugin-react; Next.js bakes it in.
Q: Module resolution — what does the dev server do?
A: When the browser requests /src/App.tsx, the dev server needs to know:
- Where on disk to find it (resolve
/src/App.tsxrelative to the project root). - How to transform it (TS + JSX → JS).
For import "./Foo":
- Browser sees
<script type="module" src="/src/App.tsx">. - The transformed
App.tsxcontainsimport "/src/Foo.tsx?t=12345"(timestamp for cache busting). - Browser requests
/src/Foo.tsx. - Server transforms + returns.
For import "react":
- Browser sees
import "react"— bare specifier (not relative or absolute). - Vite rewrites this at transform time to
import "/node_modules/.vite/deps/react.js". - Browser fetches the pre-bundled file.
Webpack’s bundled approach hides all this — the browser only sees one bundle.
Q: TypeScript path mapping in dev.
A: Your tsconfig.json has:
"paths": {
"@/*": ["./src/*"]
}
tsc understands; bundlers need a plugin (vite-tsconfig-paths, tsconfig-paths-webpack-plugin) to resolve @/components/Foo to ./src/components/Foo during build.
Without the plugin: tests + dev server fail on these imports (Node doesn’t know about tsconfig).
Q: Proxying API requests in dev.
A: Common need: frontend on localhost:3000, backend on localhost:8000. Without CORS, the dev server proxies API requests:
// vite.config.ts
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
}
fetch("/api/users") in your code → dev server forwards to localhost:8000/api/users → returns the response.
Webpack devServer has equivalent proxy config.
Q: HTTPS in dev.
A: Modern browser features (Service Worker, WebRTC, crypto.subtle, geolocation) require HTTPS. Two approaches:
localhostis treated as secure by browsers — most APIs work over HTTP on localhost.- For genuine HTTPS in dev:
vite --httpswith auto-generated certs, ormkcertfor a trusted local CA.
mkcert -install
mkcert localhost
# vite.config.ts: server.https = { key: ..., cert: ... }
Q: HMR over the network (mobile / remote dev).
A: Vite’s dev server binds to localhost by default. For mobile testing or remote dev (Cloud IDE, Codespaces):
server: {
host: true, // listen on 0.0.0.0
hmr: {
host: "your-ngrok-tunnel.io",
protocol: "wss",
port: 443,
},
}
The HMR WebSocket needs to reach the dev server — explicit host config bridges the gap. ngrok/Cloudflare Tunnel for sharing a local dev session.
Q: Why does a fresh pnpm install sometimes require restarting the dev server?
A: Vite’s dependency pre-bundling is cached based on the dep tree’s identity. A new install adds/changes deps → cache is invalidated → next start re-pre-bundles.
If the dev server is already running, it doesn’t detect this until you restart (or until you change a file that triggers a re-check). Restarting after pnpm install is the safe move.
Gotchas / edge cases
- HMR bubbles to full reload when no boundary accepts — common cause: a file changed that exports both components and non-components (Fast Refresh can’t be sure state is safe).
- Stale pre-bundled deps — Vite caches at
node_modules/.vite/deps/. Delete to force re-pre-bundling. (vite --forcedoes this.) - HMR WebSocket can’t reach the server in some network setups — symptoms: page doesn’t auto-refresh; manual reload needed. Check browser DevTools Network tab for the WS connection.
defineplugin replacements —process.env.NODE_ENVand similar are replaced at build time. Mismatch between dev and prod values causes “works in dev, broken in prod” bugs.- CSS HMR — Vite/Webpack inject changed CSS without reload; works well for utility/atomic CSS, may glitch for CSS-in-JS with runtime styles.
- Source maps in dev can be off by a line — fast modes (
eval-cheap-source-map) trade fidelity. Use richer maps when debugging. - Multiple dev servers — running two Vite servers on the same port silently uses the next free port; HMR connects to the wrong one.
What a senior is expected to say
- “Vite’s killer move: pre-bundle node_modules once with esbuild, serve source as native ESM. Cold start is sub-second; HMR is per-file.”
- “Webpack/Turbopack bundle for dev too, but rely on aggressive caching to feel fast. Different architecture; both work.”
- “HMR replaces a module’s code without reload, preserving state where possible. React Fast Refresh is the React-specific implementation — relies on the ‘one component per file’ rule.”
- “Bare specifier rewriting (
import 'react'→ pre-bundled URL) happens at transform time. TS path aliases need a bundler plugin to resolve at runtime.” - “Dev proxy for API requests sidesteps CORS during development. HTTPS via localhost is implicitly trusted; mkcert for real certs.”
Cross-references
- Vite vs Webpack vs Turbopack: 01_vite_vs_webpack_vs_turbopack.md
- Source maps: 05_source_maps.md
- Module formats (CJS/ESM): 03_module_formats.md
- HMR in React: ../05_react/
Further reading
- Vite — How and Why: https://vitejs.dev/guide/why.html
- Vite — Dep Pre-Bundling: https://vitejs.dev/guide/dep-pre-bundling.html
- Webpack — DevServer: https://webpack.js.org/configuration/dev-server/
- React Fast Refresh: https://github.com/facebook/react/tree/main/packages/react-refresh