Build Tools — Dev Servers & HMR
A development server is a local HTTP server that serves your application during development with features like hot module replacement, live reloading, and proxy configuration. The quality of your dev server directly impacts developer productivity — a slow dev server can waste hours of developer time each week.
Modern dev servers have evolved from simple file watchers to sophisticated systems using native ESM, WebSocket-based HMR, and framework-specific fast refresh. This guide covers the architecture and configuration of the most popular dev servers.
All dev servers share a common set of features, though the implementation details vary significantly. Understanding these fundamentals helps you choose the right dev server and configure it effectively.
| Feature | Vite Dev Server | webpack-dev-server | Next.js Dev |
|---|---|---|---|
| Bundler | esbuild (dev) / Rollup (build) | Webpack | Webpack / Turbopack |
| HMR approach | ESM native (no bundling) | Module graph patching | Module graph patching |
| Cold start | Instant (~50ms) | Slow (~2-10s) | Fast (~300ms-1s) |
| HMR latency | ~10ms | ~50-200ms | ~10-100ms |
| Proxy support | Built-in | Built-in | Built-in (rewrites) |
| HTTPS | Built-in | Built-in | Built-in |
info
Vite's dev server is fundamentally different from Webpack-based servers. Instead of bundling your entire application on startup, Vite serves ES modules directly to the browser. It only transforms individual files on request using esbuild for transpilation.
| 1 | // vite.config.js — dev server configuration |
| 2 | import { defineConfig } from "vite"; |
| 3 | import react from "@vitejs/plugin-react"; |
| 4 | |
| 5 | export default defineConfig({ |
| 6 | server: { |
| 7 | // Dev server port |
| 8 | port: 3000, |
| 9 | // Automatically open browser |
| 10 | open: true, |
| 11 | // Enable CORS for API requests |
| 12 | cors: true, |
| 13 | // Host for network access (0.0.0.0 for Docker) |
| 14 | host: "0.0.0.0", |
| 15 | // HMR configuration |
| 16 | hmr: { |
| 17 | // Custom WebSocket port |
| 18 | port: 34658, |
| 19 | // Protocol (ws or wss for HTTPS) |
| 20 | protocol: "ws", |
| 21 | // Timeout for HMR connection |
| 22 | timeout: 5000, |
| 23 | }, |
| 24 | // Watch options for file system |
| 25 | watch: { |
| 26 | // Use polling (useful for Docker/NFS) |
| 27 | usePolling: false, |
| 28 | // Interval for polling |
| 29 | interval: 100, |
| 30 | }, |
| 31 | }, |
| 32 | // Dependency optimization (pre-bundling) |
| 33 | optimizeDeps: { |
| 34 | include: ["react", "react-dom", "lodash-es"], |
| 35 | exclude: ["large-lib-with-many-files"], |
| 36 | }, |
| 37 | plugins: [react()], |
| 38 | }); |
info
webpack-dev-server serves your application from memory (not disk) and provides live reloading and HMR. It is the most mature dev server with extensive configuration options, though it is significantly slower than Vite for large projects.
| 1 | // webpack.config.js — dev server configuration |
| 2 | const path = require("path"); |
| 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); |
| 4 | |
| 5 | module.exports = { |
| 6 | mode: "development", |
| 7 | devtool: "eval-cheap-module-source-map", // Fast source maps |
| 8 | entry: "./src/index.js", |
| 9 | output: { |
| 10 | path: path.resolve(__dirname, "dist"), |
| 11 | publicPath: "/", |
| 12 | }, |
| 13 | devServer: { |
| 14 | port: 3000, |
| 15 | hot: true, // Enable HMR |
| 16 | liveReload: true, // Fallback to full reload if HMR fails |
| 17 | open: true, // Open browser |
| 18 | historyApiFallback: true, // SPA fallback for client-side routing |
| 19 | compress: true, // gzip compression for faster loading |
| 20 | // Static file serving |
| 21 | static: { |
| 22 | directory: path.join(__dirname, "public"), |
| 23 | }, |
| 24 | // Client configuration |
| 25 | client: { |
| 26 | overlay: { |
| 27 | errors: true, // Show errors in browser overlay |
| 28 | warnings: false, // Don't show warnings in overlay |
| 29 | }, |
| 30 | logging: "warn", // Client log level |
| 31 | progress: true, // Show compilation progress |
| 32 | }, |
| 33 | // Headers for development |
| 34 | headers: { |
| 35 | "X-Development-Mode": "true", |
| 36 | }, |
| 37 | }, |
| 38 | plugins: [ |
| 39 | new HtmlWebpackPlugin({ |
| 40 | template: "./public/index.html", |
| 41 | }), |
| 42 | ], |
| 43 | }; |
warning
Hot Module Replacement (HMR) updates modules in the browser without a full page reload. It preserves application state, making it dramatically faster for development than live reloading. The HMR pipeline involves four stages: detection, notification, update, and acceptance.
| 1 | // HMR architecture — how it works under the hood |
| 2 | // |
| 3 | // 1. File change detected by watcher (chokidar, fs.watch) |
| 4 | // 2. Build system re-compiles the changed module |
| 5 | // 3. WebSocket message sent to browser with update manifest |
| 6 | // 4. Browser runtime fetches updated module(s) |
| 7 | // 5. HMR runtime applies the update (module.hot.accept) |
| 8 | // 6. If HMR fails, fall back to full page reload |
| 9 | |
| 10 | // WebSocket message format (webpack-dev-server) |
| 11 | { |
| 12 | type: "json", |
| 13 | action: "built", |
| 14 | hash: "a1b2c3d4", |
| 15 | errors: [], |
| 16 | warnings: [], |
| 17 | modules: [ |
| 18 | { id: "./src/components/Header.tsx", hash: "e5f6g7h8" }, |
| 19 | { id: "./src/styles.css", hash: "i9j0k1l2" }, |
| 20 | ] |
| 21 | } |
| 22 | |
| 23 | // Vite HMR uses native ESM under the hood |
| 24 | // The browser fetches updated modules via regular import() |
| 25 | // No module graph patching — just re-fetch the ESM file |
| 26 | |
| 27 | // Custom HMR handling in user code |
| 28 | if (import.meta.hot) { |
| 29 | // Vite HMR API |
| 30 | import.meta.hot.accept((newModule) => { |
| 31 | // Called when this module is updated |
| 32 | render(newModule.default); |
| 33 | }); |
| 34 | |
| 35 | // Handle dispose — cleanup before re-render |
| 36 | import.meta.hot.dispose(() => { |
| 37 | destroyPreviousInstance(); |
| 38 | }); |
| 39 | } |
| 40 | |
| 41 | // Webpack HMR API |
| 42 | if (module.hot) { |
| 43 | module.hot.accept("./heavyComponent", () => { |
| 44 | const HeavyComponent = require("./heavyComponent").default; |
| 45 | render(<HeavyComponent />); |
| 46 | }); |
| 47 | } |
info
React Fast Refresh is a React-specific HMR implementation that preserves component state across edits. Unlike generic HMR, Fast Refresh understands React component boundaries and can re-render only the changed components while preserving the state of sibling and parent components.
| 1 | // React Fast Refresh — how it works |
| 2 | // Only re-renders the edited component, preserving: |
| 3 | // - Component state (useState, useReducer) |
| 4 | // - Context values |
| 5 | // - Refs |
| 6 | // - Effect cleanup and re-run |
| 7 | |
| 8 | // State preserved after edit: |
| 9 | function Counter() { |
| 10 | const [count, setCount] = useState(0); |
| 11 | // If you edit this component, count is preserved! |
| 12 | return <button onClick={() => setCount(count + 1)}>{count}</button>; |
| 13 | } |
| 14 | |
| 15 | // State RESET after edit (class component or HOC): |
| 16 | // Class components always re-mount on edit |
| 17 | class Counter extends React.Component { |
| 18 | state = { count: 0 }; |
| 19 | // Editing this class component will RESET count to 0 |
| 20 | } |
| 21 | |
| 22 | // State RESET after edit (anonymous default export): |
| 23 | export default function Counter() { |
| 24 | // Because this is an anonymous function, |
| 25 | // Fast Refresh cannot track it — state WILL reset |
| 26 | } |
| 27 | |
| 28 | // State preserved — named function component: |
| 29 | export default function Counter() { |
| 30 | // Named function — Fast Refresh can track it |
| 31 | // State is PRESERVED after edits |
| 32 | } |
| 33 | |
| 34 | // Configuring Fast Refresh in Vite |
| 35 | // @vitejs/plugin-react enables Fast Refresh automatically |
| 36 | import react from "@vitejs/plugin-react"; |
| 37 | |
| 38 | export default defineConfig({ |
| 39 | plugins: [ |
| 40 | react({ |
| 41 | fastRefresh: true, // Enabled by default |
| 42 | // Exclude components from Fast Refresh |
| 43 | exclude: /\/src\/vendor\//, |
| 44 | }), |
| 45 | ], |
| 46 | }); |
warning
Dev server proxies forward API requests from your development domain to a backend server. This avoids CORS issues during development and mirrors production where API and frontend share a domain.
| 1 | // Vite proxy configuration |
| 2 | // vite.config.js |
| 3 | export default defineConfig({ |
| 4 | server: { |
| 5 | proxy: { |
| 6 | // Forward all /api requests to backend |
| 7 | "/api": { |
| 8 | target: "http://localhost:8000", |
| 9 | changeOrigin: true, |
| 10 | // Rewrite path: remove /api prefix |
| 11 | rewrite: (path) => path.replace(/^\/api/, ""), |
| 12 | }, |
| 13 | // Forward WebSocket connections |
| 14 | "/ws": { |
| 15 | target: "ws://localhost:8000", |
| 16 | ws: true, // Enable WebSocket proxy |
| 17 | }, |
| 18 | // Forward with authentication header |
| 19 | "/graphql": { |
| 20 | target: "http://localhost:4000", |
| 21 | changeOrigin: true, |
| 22 | headers: { |
| 23 | "X-Dev-Token": "development-token", |
| 24 | }, |
| 25 | }, |
| 26 | }, |
| 27 | }, |
| 28 | }); |
| 29 | |
| 30 | // webpack-dev-server proxy configuration |
| 31 | // webpack.config.js |
| 32 | module.exports = { |
| 33 | devServer: { |
| 34 | proxy: [ |
| 35 | { |
| 36 | context: ["/api", "/auth"], |
| 37 | target: "http://localhost:8000", |
| 38 | changeOrigin: true, |
| 39 | secure: false, // Allow self-signed certificates |
| 40 | pathRewrite: { "^/api": "" }, |
| 41 | // Bypass proxy for certain paths |
| 42 | bypass: (req) => { |
| 43 | if (req.headers.accept?.includes("text/html")) { |
| 44 | return "/index.html"; // Serve index.html for SPA routes |
| 45 | } |
| 46 | }, |
| 47 | // Handle proxy errors |
| 48 | onError: (err, req, res) => { |
| 49 | res.writeHead(500, { "Content-Type": "text/plain" }); |
| 50 | res.end("Proxy error: " + err.message); |
| 51 | }, |
| 52 | }, |
| 53 | // HTTPS proxy to backend |
| 54 | { |
| 55 | context: ["/secure-api"], |
| 56 | target: "https://api.example.com", |
| 57 | secure: true, |
| 58 | headers: { |
| 59 | "X-Forwarded-Host": "localhost:3000", |
| 60 | }, |
| 61 | }, |
| 62 | ], |
| 63 | }, |
| 64 | }; |
info
Dev server performance directly impacts developer productivity. These tips help keep your dev server fast as your project grows.
- Use native ESM dev servers (Vite) — For new projects, choose Vite over Webpack for 10-100x faster cold starts.
- Exclude large files from watch — Configure server.watch.ignored to skip node_modules, build output, and generated files.
- Use fast source maps — "eval-cheap-module-source-map" (Webpack) or no source maps in dev for maximum speed.
- Lazy compile routes — Configure lazy compilation so only the current route's modules are compiled on startup.
- Disable HMR for specific modules — Some third-party modules don't support HMR. Disable it for those specific modules.
- Limit file watching scope — Use server.watch.ignored to exclude **/node_modules/**, **/.git/**, **/dist/**.
| 1 | // Vite — performance-focused configuration |
| 2 | export default defineConfig({ |
| 3 | server: { |
| 4 | watch: { |
| 5 | // Exclude large directories from file watching |
| 6 | ignored: [ |
| 7 | "**/node_modules/**", |
| 8 | "**/dist/**", |
| 9 | "**/.git/**", |
| 10 | "**/__generated__/**", |
| 11 | "**/*.snap", |
| 12 | "**/coverage/**", |
| 13 | ], |
| 14 | }, |
| 15 | // Pre-transform static imports for faster HMR |
| 16 | preTransformRequests: true, |
| 17 | }, |
| 18 | // Exclude large dependencies from optimization |
| 19 | optimizeDeps: { |
| 20 | exclude: ["large-vendor-lib"], |
| 21 | }, |
| 22 | // Cache pre-bundled dependencies |
| 23 | cacheDir: "node_modules/.vite-cache", |
| 24 | build: { |
| 25 | // Skip type checking during dev for speed |
| 26 | // (do it in a separate process or in CI) |
| 27 | }, |
| 28 | }); |
| 29 | |
| 30 | // Webpack — performance configuration |
| 31 | module.exports = { |
| 32 | // Fastest source map for development |
| 33 | devtool: "eval-cheap-module-source-map", |
| 34 | watchOptions: { |
| 35 | ignored: /node_modules/, |
| 36 | aggregateTimeout: 200, // Debounce file changes |
| 37 | poll: false, // Disable polling (use OS events) |
| 38 | }, |
| 39 | // Limit compilation scope |
| 40 | module: { |
| 41 | // Exclude node_modules from loaders (most are pre-compiled) |
| 42 | noParse: /lodash|moment/, |
| 43 | }, |
| 44 | }; |