Dev Server, HMR & Source Maps
Developer experience is not a luxury â it's a multiplier. A fast feedback loop with proper source maps and hot reloading can double your effective development speed. This is where webpack's dev tooling shines, and understanding its internals means you never cargo-cult a devtool string again.
webpack-dev-server
webpack-dev-server serves your webpack bundles from memory (not disk), provides live reloading, and proxies API requests.
Core Configuration
module.exports = {
devServer: {
port: 3000,
hot: true, // enable HMR
open: true, // open browser on start
compress: true, // gzip responses
static: {
directory: path.join(__dirname, 'public'),
},
client: {
overlay: {
errors: true,
warnings: false,
},
progress: true,
},
devMiddleware: {
stats: 'errors-warnings',
},
},
};Proxy Configuration
Proxy API requests to avoid CORS issues in development:
devServer: {
proxy: [
{
context: ['/api', '/auth'],
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' },
secure: false,
},
{
context: ['/ws'],
target: 'ws://localhost:8080',
ws: true,
},
],
}The proxy uses http-proxy-middleware under the hood. changeOrigin: true modifies the Host header to match the target â required when the backend validates the Host header.
historyApiFallback
For single-page applications with client-side routing:
devServer: {
historyApiFallback: true,
// Or with custom rewrites:
historyApiFallback: {
rewrites: [
{ from: /^\/admin/, to: '/admin.html' },
{ from: /^\/docs/, to: '/docs.html' },
{ from: /./, to: '/index.html' },
],
},
}Without this, navigating to /dashboard directly returns a 404 because the dev server looks for a physical file at that path. historyApiFallback redirects all 404s to index.html, letting your client-side router handle the URL.
Hot Module Replacement (HMR)
HMR updates modules in the browser without a full page reload, preserving application state.
How HMR Works Internally
1. File changes on disk
â
2. Webpack recompiles ONLY the changed module + dependents
â
3. Dev server sends update notification via WebSocket
â
4. HMR runtime in the browser requests the update manifest
â GET /<hash>.hot-update.json (what changed)
â GET /<hash>.hot-update.js (the actual code)
â
5. HMR runtime applies the update:
â a. Find modules that accept the update (module.hot.accept)
â b. Invalidate old modules
â c. Execute new module code
â d. If no acceptor found, bubble up to parent
â If it reaches the entry point â full page reloadThe HMR API
// Accept updates to the current module
if (module.hot) {
module.hot.accept();
}
// Accept updates to specific dependencies
if (module.hot) {
module.hot.accept('./renderer', () => {
const nextRenderer = require('./renderer');
rerender(nextRenderer);
});
}
// Cleanup before the module is replaced
if (module.hot) {
module.hot.dispose((data) => {
clearInterval(data.timerId);
// data is passed to the next version of the module
data.savedState = currentState;
});
// Restore state from previous version
if (module.hot.data?.savedState) {
currentState = module.hot.data.savedState;
}
}Update Propagation
When a module changes, HMR checks if the module or any ancestor has called module.hot.accept():
Changed: utils/format.js
â
â Does format.js call module.hot.accept()? No
â
â Does chart.js (parent) accept format.js? No
â
â Does Dashboard.jsx (grandparent) accept? Yes (via React Fast Refresh)
âââ Update applied at Dashboard.jsx levelIf no module in the chain accepts the update, HMR falls back to a full page reload. This is why framework-level HMR integration (React Fast Refresh, Vue HMR) is essential â they insert module.hot.accept() at the component level automatically.
React Fast Refresh
React Fast Refresh is the modern replacement for the deprecated React Hot Loader. It integrates with HMR to:
- Preserve component state during edits (when safe)
- Force remount when hooks change
- Show a syntax error overlay instead of crashing
// Enabled via @pmmmwh/react-refresh-webpack-plugin
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
module.exports = {
plugins: [
isDevelopment && new ReactRefreshPlugin(),
].filter(Boolean),
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: [
isDevelopment && 'react-refresh/babel',
].filter(Boolean),
},
},
},
],
},
};What Fast Refresh preserves: React state (useState, useReducer) when only JSX or render logic changes.
What triggers a remount: Changing hooks order, adding/removing hooks, changing the component signature.
What causes a full reload: Editing a file that exports non-React things alongside components.
Source Maps
Source maps connect your bundled/minified code back to your original source. The devtool option controls which type of source map webpack generates.
devtool Options Comparison
devtool | Build Speed | Rebuild Speed | Quality | Production? |
|---|---|---|---|---|
(none) | fastest | fastest | none | â |
eval | fast | fastest | generated code | No |
eval-source-map | slowest | ok | original source | No |
eval-cheap-source-map | ok | fast | transformed (lines only) | No |
eval-cheap-module-source-map | slow | fast | original (lines only) | No |
cheap-module-source-map | slow | ok | original (lines only) | Yes |
source-map | slowest | slowest | original (full) | Yes |
hidden-source-map | slowest | slowest | original (full) | Yes |
nosources-source-map | slowest | slowest | file/line only | Yes |
Choosing the Right Source Map
Development:
'eval-cheap-module-source-map'
âââ Fast rebuilds (eval wrapping)
âââ Maps to original source (module)
âââ Line-level accuracy (cheap) â good enough for most debugging
Production:
'hidden-source-map'
âââ Full quality source maps
âââ NOT referenced in the bundle (hidden)
âââ Upload to Sentry/DataDog for error tracking
Library:
'source-map'
âââ Full quality source maps
âââ Referenced in bundle â consumers can debug your libraryThe eval Wrapper
eval-based source maps wrap each module in eval() with a //# sourceURL comment. This is fast because webpack doesn't generate separate .map files â the mapping is inline. The tradeoff is that some debugger features (breakpoints in original source) may not work perfectly.
// What webpack emits with eval:
eval("const x = 1;\nconsole.log(x);\n//# sourceURL=webpack:///./src/app.js");Source Maps in Production
Never ship source maps publicly in production â they expose your source code. Instead:
module.exports = {
devtool: 'hidden-source-map',
// Source map is generated but not referenced in the bundle
};Upload to your error tracking service:
# Sentry CLI
sentry-cli releases files $VERSION upload-sourcemaps ./dist
# DataDog
datadog-ci sourcemaps upload ./dist \
--service=my-app \
--release-version=$VERSION \
--minified-path-prefix=/static/Filesystem Caching
Webpack 5 introduced persistent caching that survives across builds:
module.exports = {
cache: {
type: 'filesystem',
cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
buildDependencies: {
config: [__filename], // invalidate cache when config changes
},
version: '1.0', // bump to manually invalidate cache
},
};Cache Performance Impact
First build: 45 seconds
Subsequent builds: 3-8 seconds (filesystem cache)
Rebuild (HMR): 200-500ms (in-memory + filesystem)The filesystem cache serializes the entire module graph, resolved paths, and generated code to disk. On the next build, webpack deserializes and only reprocesses changed modules.
Cache Invalidation
Webpack automatically invalidates cache when:
- Webpack version changes
- Configuration changes (tracked via
buildDependencies) node_moduleschange (tracked viamanagedPaths)versionstring changes
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename, path.resolve(__dirname, 'babel.config.js')],
},
managedPaths: [path.resolve(__dirname, 'node_modules')],
}Watch Mode vs Dev Server
| Feature | webpack --watch | webpack-dev-server |
|---|---|---|
| Writes to disk | Yes | No (serves from memory) |
| Live reload | No | Yes |
| HMR | No | Yes |
| Proxy | No | Yes |
| Browser overlay | No | Yes |
| Speed | Slower (disk I/O) | Faster (memory) |
Use --watch when you need physical files (e.g., a backend serves them). Use webpack-dev-server for SPA development.
Development vs Production Config
Use webpack-merge to share common config between environments:
// webpack.common.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.tsx',
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js'],
alias: { '@': path.resolve(__dirname, 'src') },
},
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: 'esbuild-loader',
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }),
],
};// webpack.dev.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
module.exports = merge(common, {
mode: 'development',
devtool: 'eval-cheap-module-source-map',
devServer: {
port: 3000,
hot: true,
historyApiFallback: true,
},
module: {
rules: [
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
],
},
plugins: [new ReactRefreshPlugin()],
cache: { type: 'filesystem' },
});// webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
devtool: 'hidden-source-map',
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
clean: true,
},
module: {
rules: [
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
],
},
plugins: [
new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css' }),
],
optimization: {
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
splitChunks: { chunks: 'all' },
},
});Interview Power Moves
- "HMR works via WebSocket notification, manifest fetching, and module graph traversal â if no ancestor calls module.hot.accept(), it falls back to a full reload." Shows you understand the actual mechanism, not just the config flag.
- "I use eval-cheap-module-source-map in dev for speed and hidden-source-map in production uploaded to Sentry." Practical, specific, and correct.
- "React Fast Refresh preserves state on render changes but remounts when hooks change â that's by design, not a bug." Shows nuanced understanding of Fast Refresh boundaries.
- "Filesystem caching in webpack 5 cut our CI build from 45 seconds to 8 seconds." Real-world impact with numbers.
- "webpack-merge lets you compose configs cleanly instead of using ternaries or environment switches inside a monolithic config." Pattern knowledge that signals professional experience.