Frontend / Build tools / 08_dev_server_internals.md

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

Updated 4 interview angles 7 min read source
On this page15
  1. What does a dev server actually do?
  2. Dependency pre-bundling — what and why?
  3. Webpack doesn’t pre-bundle — what does it do instead?
  4. HMR — Hot Module Replacement, conceptually.
  5. How does React Fast Refresh interact with HMR?
  6. Module resolution — what does the dev server do?
  7. TypeScript path mapping in dev.
  8. Proxying API requests in dev.
  9. HTTPS in dev.
  10. HMR over the network (mobile / remote dev).
  11. Why does a fresh pnpm install sometimes require restarting the dev server?
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

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

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.

What does a dev server actually do?

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.

Dependency pre-bundling — what and why?

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).
text
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.

Webpack doesn’t pre-bundle — what does it do instead?

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.

HMR — Hot Module Replacement, conceptually.

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.

How does React Fast Refresh interact with HMR?

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.

Module resolution — what does the dev server do?

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.

TypeScript path mapping in dev.

Your tsconfig.json has:

json
"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).

Proxying API requests in dev.

Common need: frontend on localhost:3000, backend on localhost:8000. Without CORS, the dev server proxies API requests:

ts
// 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.

HTTPS in dev.

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.
bash
mkcert -install
mkcert localhost
# vite.config.ts:  server.https = { key: ..., cert: ... }

HMR over the network (mobile / remote dev).

Vite’s dev server binds to localhost by default. For mobile testing or remote dev (Cloud IDE, Codespaces):

ts
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.

Why does a fresh pnpm install sometimes require restarting the dev server?

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

  • 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 replacements — process.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.

Further reading

Interview angle 4

  • “Why is Vite’s dev server fast?” - it splits the problem. Dependencies are pre-bundled once with esbuild - because node_modules is huge and rarely changes - and your own source is served as native ES modules the browser requests individually. Cold start does not scale with project size, and HMR touches one file.
    • Follow-up: “Are Webpack and Turbopack doing something wrong?” - no, just different. They bundle for development too and lean on aggressive incremental caching to feel fast. Both architectures work; the ESM approach is simply less sensitive to project size.
  • “What does HMR actually do?” - swaps a module’s code in the running page and re-executes what depends on it, without a reload, preserving state where it can. React Fast Refresh is the React-specific implementation, and it relies on the one-component-per-file convention to know what to preserve.
  • “How does import "react" work in the browser?” - it does not, natively - a bare specifier is not a URL. The dev server rewrites it at transform time to point at the pre-bundled file. TypeScript path aliases need a plugin for the same reason: something must turn @/lib into a real path at runtime.
  • “How do you avoid CORS problems in development?” - proxy the API through the dev server so the browser sees one origin. For HTTPS, localhost is treated as a secure context automatically, and mkcert gives you real certificates when you need a non-localhost hostname.