DNAðŸ“Ķ Webpack & Build ToolsDev Server, HMR & Source Maps
ðŸĢHatchlingWebpackHMRDevServerDX

Dev Server, HMR & Source Maps

webpack-dev-server, Hot Module Replacement, and source maps form the core developer experience trifecta. Understanding their internals turns frustrating config issues into quick fixes.

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 reload

The 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 level

If 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:

  1. Preserve component state during edits (when safe)
  2. Force remount when hooks change
  3. 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

devtoolBuild SpeedRebuild SpeedQualityProduction?
(none)fastestfastestnone—
evalfastfastestgenerated codeNo
eval-source-mapslowestokoriginal sourceNo
eval-cheap-source-mapokfasttransformed (lines only)No
eval-cheap-module-source-mapslowfastoriginal (lines only)No
cheap-module-source-mapslowokoriginal (lines only)Yes
source-mapslowestslowestoriginal (full)Yes
hidden-source-mapslowestslowestoriginal (full)Yes
nosources-source-mapslowestslowestfile/line onlyYes

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 library

The 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_modules change (tracked via managedPaths)
  • version string changes
cache: {
  type: 'filesystem',
  buildDependencies: {
    config: [__filename, path.resolve(__dirname, 'babel.config.js')],
  },
  managedPaths: [path.resolve(__dirname, 'node_modules')],
}

Watch Mode vs Dev Server

Featurewebpack --watchwebpack-dev-server
Writes to diskYesNo (serves from memory)
Live reloadNoYes
HMRNoYes
ProxyNoYes
Browser overlayNoYes
SpeedSlower (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.