The webpack config in that project was 214 lines and had been copied from another project, which had copied it from a blog post. Nobody could say what half of it did. Version 4 shipped in February with the position that most of it should not have been necessary, and the migration was mostly deletion.
The symptom
// what a v2 production config looked like
plugins: [
new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }),
new webpack.optimize.UglifyJsPlugin({ sourceMap: true, compress: { warnings: false } }),
new webpack.optimize.ModuleConcatenationPlugin(),
new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks: isVendor }),
new webpack.optimize.CommonsChunkPlugin({ name: 'manifest' }),
new webpack.NoEmitOnErrorsPlugin(),
new webpack.HashedModuleIdsPlugin(),
// ... and eleven more
]
Every one of those is a decision somebody made once and nobody revisited. The CommonsChunkPlugin pair is the worst of it: getting vendor splitting right required a predicate function over module paths, and the version most projects had produced a vendor bundle whose hash changed on every application change — defeating the caching it existed to enable.
Why it happens
webpack 1 and 2 took the position that a bundler should do what it is told and nothing else, which is defensible and means every project has to encode the same twenty decisions. Those decisions are not project-specific — minify in production, do not minify in development — so encoding them by hand produced twenty opportunities to get a well-known answer wrong.
The other cause is CommonsChunkPlugin specifically. It was designed for a world of a few entry points and manual chunking, and it could not express the thing everybody wanted: split anything shared by enough chunks, keep the pieces above a useful size, and do not produce more parallel requests than a browser handles well.
The fix
mode, and what it implies
// webpack.config.js — the whole of it, initially
module.exports = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
entry: './resources/js/app.js',
output: {
path: path.resolve(__dirname, 'public/build'),
filename: '[name].[contenthash].js',
},
};
production minification, NODE_ENV=production, scope hoisting,
side-effect-free module removal, deterministic ids
development eval-cheap-module-source-map, named modules,
no minificationEverything in that list was in the 214 lines. Deleting them and setting one property produced byte-identical output on the first try, which is the assertion worth making before deleting anything else.
Omitting mode entirely defaults to production and prints a warning, which is a reasonable choice and confuses anyone who expected the opposite. Setting it from an environment variable rather than hard-coding is what allows the same file to serve both, and it is the one line of the config that is genuinely project-specific.
splitChunks, and the defaults being right
optimization: {
splitChunks: {
chunks: 'all', // the one change worth making
},
runtimeChunk: 'single',
},
// the defaults already do:
// split node_modules into a vendors chunk
// split anything shared by 2+ chunks
// keep chunks above 30KB
// cap parallel requests at 6 (5 for the initial load)
chunks: 'all' lets it split synchronous imports as well as dynamically imported ones, which is what most applications want and is not the default for backwards compatibility. runtimeChunk: 'single' pulls the module map into its own file so that a change to application code does not change the vendor bundle’s hash — which is the caching problem the old configuration was trying and failing to solve.
$ npm run production
Asset Size Chunks
runtime.a3f1.js 1.42 KiB 0
vendors.8c22.js 186 KiB 1
app.d914.js 24.1 KiB 2
# change one line of application code and rebuild:
# app.*.js new hash
# vendors.*.js SAME hash ← this is the point
# runtime.*.js new hash (1.4 KiB)Measuring what is in the bundle before optimising it
Guessing what is large in a bundle is unreliable, and the answer is frequently one dependency behaving badly rather than anything about the application.
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: '../bundle-report.html',
}),
]
// the finding, in almost every project that uses it:
// moment/locale/* — 330 KB of languages nobody selected
new webpack.IgnorePlugin(/^./locale$/, /moment$/)
The treemap shows parsed size by default, and switching to the gzipped view is what makes the numbers comparable to what a user actually downloads. Writing the report to a file rather than opening a server means it can be published as a build artifact, which turns bundle growth into something reviewable rather than something noticed a year later.
The loaders, which did not get simpler
Zero-config applies to optimisation and not to transformation: anything that is not JavaScript still needs a rule, and the rules are where the remaining complexity lives.
module: {
rules: [
{ test: /.js$/, exclude: /node_modules/, use: 'babel-loader' },
{
test: /.s?css$/,
use: [
MiniCssExtractPlugin.loader, // replaces ExtractTextPlugin
'css-loader', 'postcss-loader', 'sass-loader',
],
},
],
}
ExtractTextPlugin does not work with webpack 4 and MiniCssExtractPlugin is the replacement, which is the one migration step that is not deletion. Loaders in a use array run right to left, which is a detail that has confused people since version 1 and is the cause of most “my Sass is not compiling” reports.
Verifying it worked
$ git diff --stat webpack.config.js
webpack.config.js | 214 +++---------------------------------
$ md5sum public/build/app.*.js
# identical content to the v2 build, different filename hash
$ time npm run production
real 0m22.410s # was 1m14s
$ npx webpack --profile --json > stats.json
$ npx webpack-bundle-analyzer stats.jsonByte-identical output from a config that is a tenth of the size is the assertion that makes the change safe to ship on an ordinary afternoon. The build time is a bonus and comes mostly from Terser being parallel by default, which the old configuration had not enabled.
What this costs
The defaults are invisible. A build behaving unexpectedly now means reading the webpack source or the release notes rather than the project’s own configuration, and that is a genuinely worse debugging experience for the rare case — in exchange for a much better one for the common case. Writing a comment in the config naming the version and what the defaults were assumed to be is a small hedge against the next person’s confusion.
The other cost is that “zero configuration” is only true for a project whose shape matches the assumptions. A monorepo with several entry points, a library build and a server bundle needs most of the configuration back, and the marketing around the release rather oversold this. What actually changed is that the default path is now short, which is worth a great deal on the many projects that were on it and never should have needed 214 lines.