Webpack
Webpack solves the problem of shipping modular code to browsers. You write import/require statements freely, Webpack traces every dependency, transforms files that browsers can’t understand (TypeScript, SCSS, SVG), and emits optimized bundles. The trade-off is configuration — Webpack’s power comes with significant complexity, and most of the common errors are configuration mistakes.
If you’re starting a new project, consider Vite first. Webpack makes sense when you need Module Federation for micro-frontends, have an existing complex setup, or need its plugin ecosystem depth.
🟢 Junior
The core idea
Starting from an entry point, Webpack builds a dependency graph by following every import and require. It transforms each file according to your loader rules, then outputs one or more bundled files ready for the browser.
Entry point (src/index.js)
└── imports Component.jsx
└── imports styles.css
└── imports logo.svg
└── imports utils.js
└── imports lodash (from node_modules)
→ Webpack builds the full graph
→ Loaders transform each file type
→ Plugins optimize the output
→ dist/main.abc123.js
Entry, output, and mode
These three config keys are required in every Webpack setup:
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
publicPath: '/',
},
};
mode is more important than it looks. development disables minification, enables detailed error messages, and uses fast source maps. production enables tree shaking, scope hoisting, Terser minification, and dead code removal. Always set it explicitly — none disables all defaults, which is confusing.
[contenthash] in the filename changes only when the file content changes. This lets browsers cache files indefinitely and only re-download when something actually changes.
Loaders
Webpack natively handles .js and .json. Everything else — TypeScript, CSS, images, fonts — needs a loader. Loaders run right-to-left in the use array.
module.exports = {
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.(png|jpg|gif|svg|woff2?)$/,
type: 'asset',
parser: {
dataUrlCondition: { maxSize: 8 * 1024 },
},
generator: {
filename: 'assets/[hash][ext]',
},
},
],
},
};
For CSS, css-loader resolves @import and url() references, then style-loader injects the result as a <style> tag at runtime. In production you’ll swap style-loader for MiniCssExtractPlugin.loader to emit real .css files — style injection doesn’t work with HTTP/2 parallel loading, and <link> tags are faster.
For assets, type: 'asset' is a Webpack 5 built-in that replaces url-loader and file-loader. Files smaller than maxSize are inlined as base64 data URIs; larger ones are emitted as separate files.
Plugins
Loaders transform individual files. Plugins operate on the entire build — they hook into Webpack’s lifecycle events and can reshape the output in any way.
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
title: 'My App',
}),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
}),
],
};
HtmlWebpackPlugin generates your index.html with the correct <script> and <link> tags injected automatically — including the content-hashed filenames. This means you never manually update HTML when bundle names change.
Dev server
module.exports = {
devServer: {
port: 3000,
hot: true,
open: true,
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
};
Hot Module Replacement (hot: true) replaces changed modules without a full page reload. React Fast Refresh and Vue’s HMR plugin handle this automatically for component state. historyApiFallback is required for client-side routing — without it, refreshing a non-root URL returns a 404 from the dev server.
🟡 Medior
Code splitting
Loading one 2MB bundle on page load is bad UX. Code splitting breaks it into chunks that load on demand. The two mechanisms:
Dynamic imports tell Webpack to create a separate chunk file:
const Dashboard = React.lazy(
() => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);
import(/* webpackPrefetch: true */ './pages/Settings');
import(/* webpackPreload: true */ './HeavyComponent');
webpackPrefetch loads the chunk during browser idle time — the user probably needs it soon, but not right now. webpackPreload loads in parallel with the current chunk — the user needs it immediately.
SplitChunksPlugin separates vendor dependencies from your application code:
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20,
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 10,
reuseExistingChunk: true,
},
},
},
runtimeChunk: 'single',
},
};
Vendor code (React, lodash, etc.) changes far less often than your application code. Splitting it into a separate bundle means users keep it cached across deploys — only your app bundle re-downloads when you ship.
Tree shaking
Tree shaking eliminates exports that are never imported. Webpack statically analyzes import/export and marks unused code as dead. Terser then removes it.
The requirements: ES Modules only (CommonJS require() cannot be analyzed statically), and mode: 'production'.
export function getUser(id) { /* ... */ }
export function trimUser(user) { /* ... */ } // never imported anywhere → removed
import { getUser } from './utils';
getUser(1);
Tell Webpack which files are safe to tree-shake via package.json:
{ "sideEffects": false }
{ "sideEffects": ["*.css", "./src/polyfills.js"] }
A “side effect” in Webpack’s terms is code that runs on import rather than just defining exports. CSS files, polyfills, and anything that patches globals have side effects. Everything else can be marked free.
If you don’t set sideEffects: false, Webpack assumes every file might have side effects and won’t tree-shake imports that aren’t directly used. This can leave large amounts of dead code in your bundle.
Caching
Content hashes enable long-term browser caching. Files whose content didn’t change should have the same hash across builds.
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js',
},
optimization: {
runtimeChunk: 'single',
moduleIds: 'deterministic',
chunkIds: 'deterministic',
},
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
};
runtimeChunk: 'single' is the important one. Without it, adding or removing any module changes the module IDs of everything else, which changes the vendor bundle’s hash even though vendor code didn’t change. Separating Webpack’s runtime into its own tiny file stabilizes vendor hashes across builds.
cache: { type: 'filesystem' } persists the build cache to disk. Subsequent builds that haven’t changed much can be 80-90% faster.
Source maps
Source maps map minified code back to your original source. This is what makes production error stack traces readable.
Development: use eval-source-map for good quality with fast rebuilds.
Production: use hidden-source-map — generates the .map files but doesn’t link them from the bundle. Upload them to Sentry or your error monitoring tool. Users can’t access your source, but your errors are debuggable.
module.exports = {
devtool: process.env.NODE_ENV === 'production'
? 'hidden-source-map'
: 'eval-source-map',
};
🔴 Senior
Splitting dev and production configs
A single config file that tries to handle both environments gets messy. Split into three files with webpack-merge:
// webpack.common.js
const common = { entry: ..., module: ..., resolve: ... };
// webpack.dev.js
const { merge } = require('webpack-merge');
module.exports = merge(common, {
mode: 'development',
devtool: 'eval-source-map',
devServer: { hot: true, port: 3000 },
});
// webpack.prod.js
module.exports = merge(common, {
mode: 'production',
devtool: 'hidden-source-map',
plugins: [new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' })],
optimization: {
minimize: true,
splitChunks: { chunks: 'all' },
runtimeChunk: 'single',
},
});
{
"scripts": {
"dev": "webpack serve --config webpack.dev.js",
"build": "webpack --config webpack.prod.js"
}
}
Module Federation
Module Federation is Webpack 5’s killer feature for micro-frontends. Independently deployed applications share code at runtime without a build-time dependency between them.
// Host app — consumes from remotes
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
},
});
// Checkout app — exposes its components
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./Cart': './src/components/Cart',
'./Checkout': './src/pages/Checkout',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});
// Usage in the host — loaded at runtime, not at build time
const Cart = React.lazy(() => import('checkout/Cart'));
singleton: true for shared dependencies is critical. Without it, the host and each remote would load their own copy of React, and multiple React instances cause subtle rendering bugs and broken hooks.
Bundle analysis
When your bundle is too large, you need to see what’s in it:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins: [
process.env.ANALYZE && new BundleAnalyzerPlugin(),
].filter(Boolean),
ANALYZE=true npm run build
The most common findings: Moment.js pulling in every locale (~500kb) — replace with date-fns. Full lodash (~70kb) — use lodash-es for tree-shaking or cherry-pick individual functions. Duplicate packages at different versions — use npm ls or npm dedupe.
Senior Gotchas
sideEffects: false on a package that has side effects silently breaks things. CSS imports that polyfill or patch globals will be stripped. Test that tree-shaking works before committing to it on complex packages.
CommonJS blocks tree shaking. If lodash is CommonJS (it is), you can’t tree-shake it. Use lodash-es or lodash/debounce cherry-picks instead. This applies to any dependency using module.exports.
runtimeChunk: 'single' is almost always required. Without it, adding a new module anywhere changes the IDs of existing modules, which changes the vendor chunk hash and busts the browser cache for code that didn’t change.
Circular dependencies are resolved by Webpack but cause runtime errors where one module sees undefined from the other (the uninitialized circular import). The circular-dependency-plugin catches these at build time.
thread-loader startup cost is ~600ms. Spawning a worker pool takes time. Only use it on large TypeScript+Babel codebases where the per-file cost exceeds the startup overhead.
Dev and production configs should be genuinely different. Running Terser, MiniCssExtractPlugin, and full source maps in development slows down every rebuild for no benefit. Split the configs.