Tooling

Module Bundling with Webpack: Practical Configuration

A practical guide to webpack configuration covering loaders, plugins, code splitting, tree shaking, dev server, and production optimization for JavaScript projects.

Module Bundling with Webpack: Practical Configuration

Overview

Webpack transforms a tangled web of JavaScript files, CSS stylesheets, images, and other assets into optimized bundles that browsers can load efficiently. It is the most widely adopted bundler in the JavaScript ecosystem, and for good reason -- it handles complex dependency graphs, supports every module format, and gives you granular control over how your code is processed and delivered.

I have configured webpack for dozens of production projects over the past eight years, from small Express.js apps with a frontend component to large single-page applications serving millions of users. The biggest mistake I see teams make is copy-pasting webpack configs from Stack Overflow without understanding what each piece does. When the build breaks -- and it will break -- they have no idea where to start debugging. This article walks through every important piece of webpack configuration with working examples, explains why each option exists, and gives you a complete production-ready setup for an Express.js project with a frontend.

Prerequisites

  • Node.js 18 or later
  • npm or yarn
  • Basic understanding of JavaScript modules (CommonJS require() and ES module import)
  • A terminal and a code editor

Project Setup

Start with a clean project and install webpack along with the core dependencies we will use throughout this article:

mkdir webpack-express-app
cd webpack-express-app
npm init -y
npm install express
npm install --save-dev webpack webpack-cli webpack-dev-server
npm install --save-dev babel-loader @babel/core @babel/preset-env
npm install --save-dev css-loader style-loader mini-css-extract-plugin
npm install --save-dev html-webpack-plugin
npm install --save-dev webpack-merge
npm install --save-dev webpack-bundle-analyzer

Create the project structure:

webpack-express-app/
  server.js
  src/
    index.js
    app.js
    utils/
      api.js
      formatting.js
    styles/
      main.css
      components.css
    images/
      logo.png
  webpack/
    webpack.common.js
    webpack.dev.js
    webpack.prod.js
  dist/
  public/
    index.html

Entry and Output Configuration

The entry point tells webpack where to start building the dependency graph. The output configuration tells it where to write the bundled files and how to name them.

// webpack/webpack.common.js
var path = require("path");

module.exports = {
  entry: {
    main: path.resolve(__dirname, "../src/index.js"),
    vendor: path.resolve(__dirname, "../src/vendor.js")
  },
  output: {
    path: path.resolve(__dirname, "../dist"),
    filename: "[name].[contenthash].js",
    clean: true,
    publicPath: "/"
  }
};

The [name] token maps to the entry point key -- main or vendor in this case. The [contenthash] token generates a hash based on the file contents, which is critical for cache busting. When your code changes, the hash changes, and browsers fetch the new file instead of serving a stale cached version. The clean: true option deletes old files from dist/ before each build so you do not accumulate stale bundles.

A common mistake is using [hash] instead of [contenthash]. The [hash] token is generated per build -- every file gets the same hash even if only one file changed. With [contenthash], only files whose content actually changed get new hashes, so browsers can keep caching the files that did not change.

Loaders

Loaders are transformations that webpack applies to individual files as it processes the dependency graph. They run from right to left (or bottom to top in array notation).

Babel Loader

Babel transpiles modern JavaScript to a version that older browsers can understand. Even if you are not using the latest syntax, babel-loader is worth including for consistent behavior across environments.

// In the module.rules array
{
  test: /\.js$/,
  exclude: /node_modules/,
  use: {
    loader: "babel-loader",
    options: {
      presets: [
        ["@babel/preset-env", {
          targets: "> 0.25%, not dead",
          useBuiltIns: "usage",
          corejs: 3
        }]
      ],
      cacheDirectory: true
    }
  }
}

The cacheDirectory: true option caches babel compilation results in node_modules/.cache/babel-loader. On a project with 500 JavaScript files, this cuts rebuild time from 12 seconds to under 2 seconds. Always enable it.

CSS Loaders

CSS handling requires two loaders working together. css-loader resolves @import and url() statements inside CSS files. style-loader injects the processed CSS into the DOM via <style> tags at runtime. In production, you will swap style-loader for MiniCssExtractPlugin.loader to extract CSS into separate files.

// Development CSS rule
{
  test: /\.css$/,
  use: ["style-loader", "css-loader"]
}

Remember that loaders execute right to left. css-loader runs first, resolving imports and URLs. Then style-loader takes the result and injects it into the page. Reversing the order will throw an error.

Asset Modules

Webpack 5 introduced asset modules as a built-in replacement for file-loader, url-loader, and raw-loader. Use them instead of installing those separate packages.

// Handle images
{
  test: /\.(png|jpg|jpeg|gif|svg)$/i,
  type: "asset",
  parser: {
    dataUrlCondition: {
      maxSize: 8 * 1024 // 8KB -- inline smaller images as base64
    }
  },
  generator: {
    filename: "images/[name].[contenthash][ext]"
  }
}

// Handle fonts
{
  test: /\.(woff|woff2|eot|ttf|otf)$/i,
  type: "asset/resource",
  generator: {
    filename: "fonts/[name].[contenthash][ext]"
  }
}

The asset type automatically decides whether to inline a file as base64 (for small files) or emit it as a separate file (for large files). The maxSize threshold controls the cutoff. Images under 8KB get inlined, which saves an HTTP request. Images over 8KB get emitted as separate files, which avoids bloating your JavaScript bundle with large base64 strings.

Plugins

Plugins operate on the entire bundle rather than individual files. They handle tasks like generating HTML files, extracting CSS, defining global constants, and analyzing bundle size.

HtmlWebpackPlugin

This plugin generates an HTML file that automatically includes <script> and <link> tags pointing to your bundled files. Without it, you would have to manually update your HTML every time the content hash changes.

var HtmlWebpackPlugin = require("html-webpack-plugin");

// In the plugins array
new HtmlWebpackPlugin({
  template: path.resolve(__dirname, "../public/index.html"),
  filename: "index.html",
  chunks: ["main"],
  minify: {
    collapseWhitespace: true,
    removeComments: true,
    removeRedundantAttributes: true
  }
})

The chunks option controls which entry points are included in this HTML file. If you have multiple entry points and multiple HTML pages, you create one HtmlWebpackPlugin instance per page, each specifying its own chunks.

MiniCssExtractPlugin

In production, you want CSS in separate files so browsers can cache them independently and load them in parallel with JavaScript. MiniCssExtractPlugin extracts CSS from your JavaScript bundles into dedicated .css files.

var MiniCssExtractPlugin = require("mini-css-extract-plugin");

// In the plugins array
new MiniCssExtractPlugin({
  filename: "css/[name].[contenthash].css"
})

// Replace style-loader with MiniCssExtractPlugin.loader in the CSS rule
{
  test: /\.css$/,
  use: [MiniCssExtractPlugin.loader, "css-loader"]
}

DefinePlugin

DefinePlugin replaces variables in your code at compile time. This is how you inject environment-specific configuration into your frontend bundles.

var webpack = require("webpack");

new webpack.DefinePlugin({
  "process.env.API_URL": JSON.stringify(process.env.API_URL || "http://localhost:3000"),
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
  "__APP_VERSION__": JSON.stringify(require("../package.json").version)
})

The JSON.stringify() call is not optional. DefinePlugin performs a direct text replacement, so without JSON.stringify(), the value http://localhost:3000 would appear as a bare expression in the code, causing a syntax error. Wrapping it in JSON.stringify() produces "http://localhost:3000" -- a properly quoted string.

Dev Server with Hot Module Replacement

Webpack Dev Server serves your bundle from memory and automatically reloads the browser when files change. Hot Module Replacement (HMR) goes further by patching only the modules that changed, preserving application state.

// webpack/webpack.dev.js
var common = require("./webpack.common.js");
var { merge } = require("webpack-merge");
var path = require("path");

module.exports = merge(common, {
  mode: "development",
  devtool: "eval-source-map",
  devServer: {
    static: {
      directory: path.resolve(__dirname, "../dist")
    },
    port: 9000,
    hot: true,
    open: true,
    historyApiFallback: true,
    proxy: [
      {
        context: ["/api"],
        target: "http://localhost:3000",
        changeOrigin: true
      }
    ]
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"]
      }
    ]
  }
});

The proxy option is essential for Express.js projects. Your frontend runs on port 9000 via the dev server, and your Express API runs on port 3000. The proxy forwards any requests starting with /api to your Express server, avoiding CORS issues during development.

The historyApiFallback: true option makes the dev server return index.html for any route that does not match a file. This is required for single-page applications that use client-side routing.

Source Maps

Source maps connect bundled code back to your original source files, making debugging possible. The devtool option controls source map generation.

// Development -- fast rebuilds, good quality maps
devtool: "eval-source-map"

// Production -- separate .map files, full quality
devtool: "source-map"

// Production alternative -- no source maps (fastest build)
devtool: false

I use eval-source-map in development because it provides accurate line-by-line mapping with fast rebuild times. In production, I use source-map to generate separate .map files that are only downloaded when a user opens the browser DevTools. Some teams use hidden-source-map in production, which generates the map files but does not reference them in the bundle -- you can then upload the maps to an error tracking service like Sentry without exposing them to end users.

Code Splitting

Code splitting is the single most impactful webpack optimization. Instead of shipping one massive bundle, you split your code into smaller chunks that load on demand.

Dynamic Imports

Use import() to split code at specific points. Webpack automatically creates a separate chunk for each dynamic import.

// src/app.js
function loadDashboard() {
  return import(/* webpackChunkName: "dashboard" */ "./pages/dashboard.js")
    .then(function(module) {
      var Dashboard = module.default;
      Dashboard.render();
    });
}

function loadSettings() {
  return import(/* webpackChunkName: "settings" */ "./pages/settings.js")
    .then(function(module) {
      var Settings = module.default;
      Settings.render();
    });
}

// Route handler
function handleRoute(route) {
  if (route === "/dashboard") {
    loadDashboard();
  } else if (route === "/settings") {
    loadSettings();
  }
}

The webpackChunkName magic comment controls the output filename for the chunk. Without it, chunks get numeric names like 0.js, 1.js, which are useless for debugging.

SplitChunks Optimization

The splitChunks configuration controls how webpack splits shared code into separate chunks. The default configuration is usually sufficient, but you can tune it for better results.

// In the optimization section
optimization: {
  splitChunks: {
    chunks: "all",
    maxInitialRequests: 20,
    maxAsyncRequests: 20,
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: function(module) {
          var packageName = module.context.match(
            /[\\/]node_modules[\\/](.*?)([\\/]|$)/
          )[1];
          return "vendor." + packageName.replace("@", "");
        },
        chunks: "all"
      },
      common: {
        minChunks: 2,
        priority: -10,
        reuseExistingChunk: true
      }
    }
  },
  runtimeChunk: "single"
}

The vendor cache group splits each node_modules package into its own chunk. This means when you update lodash, only the vendor.lodash chunk hash changes -- everything else stays cached. The runtimeChunk: "single" option extracts webpack's runtime code into a separate chunk so that it does not cause unnecessary cache invalidation in your application chunks.

Tree Shaking

Tree shaking eliminates unused exports from your bundles. It works automatically in production mode, but your code must cooperate.

// src/utils/formatting.js -- tree-shakeable
// Export individual functions instead of a single object
module.exports.formatCurrency = function(amount) {
  return "$" + amount.toFixed(2);
};

module.exports.formatDate = function(date) {
  return new Date(date).toLocaleDateString("en-US");
};

module.exports.formatPercentage = function(value) {
  return (value * 100).toFixed(1) + "%";
};
// src/app.js -- only formatCurrency is included in the bundle
var formatCurrency = require("./utils/formatting").formatCurrency;

console.log(formatCurrency(29.99));

For tree shaking to work effectively, mark your package as side-effect-free in package.json:

{
  "name": "webpack-express-app",
  "sideEffects": ["*.css"]
}

The sideEffects array tells webpack which files have side effects (like CSS imports) and cannot be safely eliminated. Everything else is fair game for tree shaking. Without this field, webpack assumes every file has side effects and cannot remove unused exports.

Production vs Development Configs

Never use a single webpack config for both environments. The requirements are fundamentally different. Development needs fast rebuilds and helpful error messages. Production needs small bundles and maximum optimization.

Common Configuration

// webpack/webpack.common.js
var path = require("path");
var HtmlWebpackPlugin = require("html-webpack-plugin");
var webpack = require("webpack");

module.exports = {
  entry: {
    main: path.resolve(__dirname, "../src/index.js")
  },
  output: {
    path: path.resolve(__dirname, "../dist"),
    filename: "[name].[contenthash].js",
    clean: true,
    publicPath: "/"
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [
              ["@babel/preset-env", {
                targets: "> 0.25%, not dead",
                useBuiltIns: "usage",
                corejs: 3
              }]
            ],
            cacheDirectory: true
          }
        }
      },
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/i,
        type: "asset",
        parser: {
          dataUrlCondition: { maxSize: 8 * 1024 }
        },
        generator: {
          filename: "images/[name].[contenthash][ext]"
        }
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/i,
        type: "asset/resource",
        generator: {
          filename: "fonts/[name].[contenthash][ext]"
        }
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "../public/index.html"),
      filename: "index.html"
    }),
    new webpack.DefinePlugin({
      "__APP_VERSION__": JSON.stringify(require("../package.json").version)
    })
  ],
  resolve: {
    alias: {
      "@utils": path.resolve(__dirname, "../src/utils"),
      "@styles": path.resolve(__dirname, "../src/styles"),
      "@components": path.resolve(__dirname, "../src/components"),
      "@pages": path.resolve(__dirname, "../src/pages")
    },
    extensions: [".js", ".json"]
  }
};

Development Configuration

// webpack/webpack.dev.js
var common = require("./webpack.common.js");
var { merge } = require("webpack-merge");
var webpack = require("webpack");
var path = require("path");

module.exports = merge(common, {
  mode: "development",
  devtool: "eval-source-map",
  output: {
    filename: "[name].js" // No contenthash in dev for faster builds
  },
  devServer: {
    static: {
      directory: path.resolve(__dirname, "../dist")
    },
    port: 9000,
    hot: true,
    open: true,
    historyApiFallback: true,
    proxy: [
      {
        context: ["/api"],
        target: "http://localhost:3000",
        changeOrigin: true
      }
    ],
    client: {
      overlay: {
        errors: true,
        warnings: false
      }
    }
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"]
      }
    ]
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.API_URL": JSON.stringify("http://localhost:3000")
    })
  ]
});

Production Configuration

// webpack/webpack.prod.js
var common = require("./webpack.common.js");
var { merge } = require("webpack-merge");
var MiniCssExtractPlugin = require("mini-css-extract-plugin");
var CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
var TerserPlugin = require("terser-webpack-plugin");
var webpack = require("webpack");
var BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;

module.exports = merge(common, {
  mode: "production",
  devtool: "source-map",
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "css/[name].[contenthash].css"
    }),
    new webpack.DefinePlugin({
      "process.env.API_URL": JSON.stringify(process.env.API_URL || "https://api.example.com")
    }),
    new BundleAnalyzerPlugin({
      analyzerMode: "static",
      openAnalyzer: false,
      reportFilename: "bundle-report.html"
    })
  ],
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true // Remove console.log in production
          }
        }
      }),
      new CssMinimizerPlugin()
    ],
    splitChunks: {
      chunks: "all",
      maxInitialRequests: 20,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: function(module) {
            var packageName = module.context.match(
              /[\\/]node_modules[\\/](.*?)([\\/]|$)/
            )[1];
            return "vendor." + packageName.replace("@", "");
          },
          chunks: "all"
        }
      }
    },
    runtimeChunk: "single"
  },
  performance: {
    maxAssetSize: 250000, // 250KB
    maxEntrypointSize: 400000, // 400KB
    hints: "warning"
  }
});

Package.json Scripts

{
  "scripts": {
    "start": "node server.js",
    "dev": "webpack serve --config webpack/webpack.dev.js",
    "build": "webpack --config webpack/webpack.prod.js",
    "build:analyze": "ANALYZE=true webpack --config webpack/webpack.prod.js",
    "build:stats": "webpack --config webpack/webpack.prod.js --json > stats.json"
  }
}

Resolve Aliases

The resolve.alias configuration shown in the common config above maps shorthand paths to directories. Instead of writing fragile relative imports like require("../../../utils/api"), you write require("@utils/api"). This makes refactoring easier and keeps imports readable as the project grows.

// Without aliases -- fragile, hard to read
var api = require("../../../utils/api");
var formatDate = require("../../utils/formatting").formatDate;

// With aliases -- clean and portable
var api = require("@utils/api");
var formatDate = require("@utils/formatting").formatDate;

If you use a linter like ESLint, install eslint-import-resolver-webpack so ESLint understands your aliases and does not flag them as unresolved imports.

Bundle Analysis

The webpack-bundle-analyzer plugin generates an interactive treemap visualization of your bundle contents. It shows exactly which modules are taking up space and helps you identify opportunities for optimization.

var BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;

// Conditionally include the analyzer
var plugins = [];
if (process.env.ANALYZE === "true") {
  plugins.push(new BundleAnalyzerPlugin({
    analyzerMode: "server",
    analyzerPort: 8888,
    openAnalyzer: true
  }));
} else {
  plugins.push(new BundleAnalyzerPlugin({
    analyzerMode: "static",
    openAnalyzer: false,
    reportFilename: "bundle-report.html"
  }));
}

Run npm run build:analyze and examine the visualization. Common findings include:

  • Moment.js locales: Moment ships every locale by default, adding 200KB+. Use webpack.IgnorePlugin to exclude them: new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/ })
  • Lodash: Importing all of lodash for one function. Use lodash.debounce instead of lodash
  • Duplicate dependencies: Different versions of the same package nested in node_modules. Run npm dedupe to resolve

Performance Budgets

The performance configuration warns you when bundles exceed size thresholds. This prevents gradual bundle bloat where each developer adds "just one more dependency" until the initial page load takes 8 seconds.

performance: {
  maxAssetSize: 250000,      // Warn if any single asset exceeds 250KB
  maxEntrypointSize: 400000, // Warn if entry point exceeds 400KB
  hints: "error",            // Fail the build instead of just warning
  assetFilter: function(assetFilename) {
    return assetFilename.endsWith(".js") || assetFilename.endsWith(".css");
  }
}

Setting hints: "error" makes webpack fail the build when a budget is exceeded. This is aggressive, but it forces the team to deal with bundle size immediately rather than letting it pile up. You can use "warning" if you prefer a softer approach.

Complete Working Example

Here is the Express.js server that serves the webpack-built frontend in production and runs alongside the dev server during development:

// server.js
var express = require("express");
var path = require("path");
var app = express();
var PORT = process.env.PORT || 3000;

// Serve static files from webpack output in production
if (process.env.NODE_ENV === "production") {
  app.use(express.static(path.join(__dirname, "dist"), {
    maxAge: "1y",       // Cache static assets for 1 year
    immutable: true     // contenthash means files never change
  }));
}

// API routes
app.use(express.json());

app.get("/api/health", function(req, res) {
  res.json({ status: "ok", version: require("./package.json").version });
});

app.get("/api/data", function(req, res) {
  res.json({
    items: [
      { id: 1, name: "Widget A", price: 29.99 },
      { id: 2, name: "Widget B", price: 49.99 }
    ]
  });
});

// SPA fallback -- serve index.html for all non-API routes in production
if (process.env.NODE_ENV === "production") {
  app.get("*", function(req, res) {
    res.sendFile(path.join(__dirname, "dist", "index.html"));
  });
}

app.listen(PORT, function() {
  console.log("Server running on port " + PORT);
});
// src/index.js -- Application entry point
require("@styles/main.css");
var app = require("./app");

document.addEventListener("DOMContentLoaded", function() {
  app.init();
});
// src/app.js -- Main application logic
var api = require("@utils/api");
var formatting = require("@utils/formatting");

var App = {
  init: function() {
    console.log("App initialized, version: " + __APP_VERSION__);
    this.loadData();
    this.setupRouting();
  },

  loadData: function() {
    api.get("/api/data").then(function(data) {
      var container = document.getElementById("app");
      var html = data.items.map(function(item) {
        return "<div class='item'>" +
          "<h3>" + item.name + "</h3>" +
          "<p>" + formatting.formatCurrency(item.price) + "</p>" +
          "</div>";
      }).join("");
      container.innerHTML = html;
    });
  },

  setupRouting: function() {
    window.addEventListener("popstate", function() {
      var route = window.location.pathname;
      if (route === "/dashboard") {
        import(/* webpackChunkName: "dashboard" */ "@pages/dashboard")
          .then(function(module) {
            module.default.render();
          });
      }
    });
  }
};

module.exports = App;
// src/utils/api.js -- Simple fetch wrapper
var API_URL = process.env.API_URL || "";

module.exports.get = function(endpoint) {
  return fetch(API_URL + endpoint)
    .then(function(response) {
      if (!response.ok) {
        throw new Error("HTTP " + response.status);
      }
      return response.json();
    });
};

module.exports.post = function(endpoint, data) {
  return fetch(API_URL + endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data)
  }).then(function(response) {
    if (!response.ok) {
      throw new Error("HTTP " + response.status);
    }
    return response.json();
  });
};

Common Issues and Troubleshooting

1. "Module not found" Errors with Aliases

If webpack resolves your aliases but ESLint or your editor complains about unresolved modules, the issue is that those tools do not read webpack.config.js. Install eslint-import-resolver-webpack and add it to your ESLint config:

{
  "settings": {
    "import/resolver": {
      "webpack": {
        "config": "webpack/webpack.common.js"
      }
    }
  }
}

For VS Code, create a jsconfig.json at the project root that mirrors your aliases:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@utils/*": ["src/utils/*"],
      "@styles/*": ["src/styles/*"],
      "@components/*": ["src/components/*"],
      "@pages/*": ["src/pages/*"]
    }
  }
}

2. CSS Not Updating in Development

If CSS changes are not reflected in the browser during development, check that you are using style-loader and not MiniCssExtractPlugin.loader in your dev config. style-loader injects CSS via JavaScript and supports HMR. MiniCssExtractPlugin.loader writes static CSS files and does not support HMR out of the box.

3. Build Succeeds but Page is Blank

This usually means HtmlWebpackPlugin is not injecting the correct chunks, or the publicPath is wrong. Check three things:

  • The chunks option in HtmlWebpackPlugin matches your entry point names
  • The publicPath in your output config matches where the files are actually served from
  • If using historyApiFallback, make sure your server has a catch-all route that serves index.html

4. Bundle Size Explodes After Adding a Dependency

Run npm run build:analyze to identify the culprit. Common offenders include moment (330KB with locales), lodash (72KB full library), and UI frameworks that do not tree-shake well. Solutions:

  • Use date-fns instead of moment (tree-shakeable)
  • Import individual lodash functions: require("lodash.debounce") instead of require("lodash")
  • Use webpack.IgnorePlugin to exclude unused locale data or optional dependencies
  • Check for duplicate packages with npm ls <package-name>

5. Source Maps Missing in Production

If errors in production show minified code without source maps, verify that devtool: "source-map" is set in your production config and that your web server is not stripping .map files. Nginx, for example, will not serve .map files unless the MIME type is configured. Add application/json map; to your MIME types or serve map files with the correct Content-Type header.

6. Dev Server Proxy Returns 504 Gateway Timeout

The dev server proxy has a default timeout that can be too short for slow API endpoints. Increase it:

proxy: [
  {
    context: ["/api"],
    target: "http://localhost:3000",
    changeOrigin: true,
    timeout: 30000,
    proxyTimeout: 30000
  }
]

Best Practices

  1. Always use contenthash in production filenames. This gives you aggressive caching with automatic cache busting. Set Cache-Control: max-age=31536000, immutable on your static file server and never worry about stale assets again.

  2. Split your config into three files. Common, development, and production. Use webpack-merge to compose them. A single config with ternary operators everywhere becomes unreadable fast.

  3. Enable Babel's cacheDirectory. This is free performance. On medium-sized projects, it cuts rebuild time by 60-80%. There is no reason to skip it.

  4. Set performance budgets from day one. It is much easier to maintain a 250KB budget than to cut a 2MB bundle down to size after a year of unchecked growth. Use hints: "error" in CI pipelines to enforce the budget.

  5. Run bundle analysis on every major release. Dependencies drift. That "tiny utility" someone added three months ago might be pulling in a 150KB transitive dependency. Schedule a quarterly bundle audit and compare against previous reports.

  6. Use sideEffects in package.json. Without this flag, webpack cannot safely tree-shake your code. Mark CSS files and any modules with global side effects, and let webpack eliminate everything else.

  7. Keep node_modules out of Babel. The exclude: /node_modules/ rule in babel-loader is critical. Transpiling node_modules adds minutes to your build time and most packages already ship transpiled code. Only add specific exceptions if a package ships untranspiled ES6+.

  8. Extract the runtime chunk. Setting runtimeChunk: "single" prevents webpack's bootstrap code from changing the hash of your application chunks when no application code changed. This improves long-term caching.

References

Powered by Contentful