frontend / build tools / 08_dev_server_internals.md

Dev-Server Internals — HMR, Pre-Bundling, Dependency Resolution

6 min read source

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:

  1. Watches your filesystem.
  2. Receives HTTP requests from the browser (GET /src/App.tsx).
  3. Runs your file through transformers (TS → JS, JSX → JS, CSS preprocessing, etc.).
  4. Returns the transformed file.
  5. 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 (react becomes 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:

  1. Build tool detects the change.
  2. Computes the HMR boundary — which module is the smallest replaceable unit (a “self-accepting” module, like a React component file).
  3. Sends an HMR update message to the browser via WebSocket.
  4. Browser receives, fetches the new module code.
  5. Runtime replaces the old module with the new — calls registered dispose callbacks, runs the new module’s setup, propagates to importers.
  6. 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:

  1. Where on disk to find it (resolve /src/App.tsx relative to the project root).
  2. How to transform it (TS + JSX → JS).

For import "./Foo":

  1. Browser sees <script type="module" src="/src/App.tsx">.
  2. The transformed App.tsx contains import "/src/Foo.tsx?t=12345" (timestamp for cache busting).
  3. Browser requests /src/Foo.tsx.
  4. Server transforms + returns.

For import "react":

  1. Browser sees import "react" — bare specifier (not relative or absolute).
  2. Vite rewrites this at transform time to import "/node_modules/.vite/deps/react.js".
  3. 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:

  • localhost is treated as secure by browsers — most APIs work over HTTP on localhost.
  • For genuine HTTPS in dev: vite --https with auto-generated certs, or mkcert for 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 --force does 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.
  • define plugin replacementsprocess.env.NODE_ENV and 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

Further reading