Bundle optimization
Usually, the number of website assets grows over time. With more business features comes more functionality, which means more JavaScript, CSS, images, fonts, etc. We already touched on optimizing assets in the previous section by caching less‑frequently changing content. Now it is time to talk about optimizing the assets themselves. By optimizing, we mean reducing size and/or build time without quality loss.
Dependencies #
terser-webpack-plugin- webpack plugin for Terser (JavaScript uglifier, minifier, etc.)@types/terser-webpack-plugin- type definitions for the terser webpack plugin
npm i terser-webpack-plugin @types/terser-webpack-plugin -D --save-exact
Babel #
During development, the Babel loader is used extensively. JavaScript transformations performed by Babel can be optimized for execution time by caching previous results.
webpack/parts.ts #
Change webpack/parts.ts to include the option:
// ...
loader: 'babel-loader',
options: {
cacheDirectory: true
},
//...
cacheDirectory: Default false. When set, the given directory will be used to cache the results of the loader. Future webpack builds will attempt to read from the cache to avoid needing to run the potentially expensive babel recompilation process on each run. If the value is set to true in options ({cacheDirectory: true}), the loader will use the default cache directory in node_modules/.cache/babel-loader or fallback to the default OS temporary file directory if no node_modules folder could be found in any root directory.
Another useful optimization we talked about before is the @babel/plugin-transform-runtime plugin. Introducing this plugin tells Babel to include runtime code as a separate package instead of inlining it in every file that needs it. This minimizes the overall bundle size.
Vendors #
For now, all the third‑party JavaScript code used by our webpack setup is combined into one chunk file — vendors. From the caching point of view, if no changes were introduced to the third‑party code, clients don’t need to download the vendors chunk. Good start, but we can take this idea to the next step. Instead of splitting between vendor and non‑vendor code, we can split the vendors chunk into multiple chunks — one per vendor. Yes, there will be a lot more chunks, but each chunk will be significantly smaller and clients will download only the chunks that changed. This optimization requires a different way of thinking about how browsers download/cache assets and what happens during the page loading/rendering life cycle.
Since the introduction of HTTP/2, the head‑of‑line blocking issue was resolved in favor of multiplexed request/response delivery. That means more resources can be downloaded in parallel. Combined with payload compression (vs. HTTP/1.1 plain text), multiple‑small‑files strategies can perform well. And, of course, every optimization depends on the application and should be verified empirically. In other words, if a single vendor chunk works for you, splitting it into multiple chunks might be overkill.
webpack/webpack.config.dev.ts #
Change webpack/parts.ts to exclude the optimization option and move it to webpack/webpack.config.dev.ts:
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/i,
name: 'vendors',
chunks: 'all'
},
}
},
runtimeChunk: {
name: 'vendors'
}
}
In our case, we keep a single vendor chunk in development mode to keep bundling fast.
webpack/webpack.config.ts #
Change webpack/webpack.config.ts to include split by vendor optimization:
optimization: {
// ...
usedExports: true,
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 0,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/i,
name(module) {
const packageName =
module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
return `vendor.${packageName.replace('@', '')}`;
}
}
}
}
We covered some of these options previously, but let's go through them one by one anyway:
usedExports- Determines used exports. Helps other optimizationsruntimeChunk- Creates a separate chunk for the webpack runtime code itselfsplitChunks- Reduces code duplication by finding modules shared between chunks and splitting them into separate chunkschunks: all- Picks specific chunks for shared‑module inspection (in our case, all chunks, no exceptions)maxInitialRequests: Infinity- Maximum number of initial chunks which are accepted for an entry point (no limitation)minSize: 0- Minimal size for the created chunk (no limitation)cacheGroups- Separate modules by groups. Separate group modules go into separate chunksvendors- Cache group nametest- Regex to determine file paths under inspection. In this case, anything that hasnode_modulesin the pathname(module)- Function generates a name of the chunk
The name function is the most important piece of the puzzle. First, it extracts the package name out of the path, assuming it follows the node_modules folder. Second, it returns a formatted chunk name starting with the vendor keyword. This way, every npm package is assigned a separate chunk and thus a separate file (no matter how big or small it is).
The
namefunction could return identical values for different npm packages. In this case, all the affected packages will be moved to the same chunk. This allows extra optimization by gluing packages together if needed.
Source maps #
Using a cheap variant of source maps helps development flow. For production we still use source-map. For development, there are plenty of options available — pick the one that best balances bundling speed and debuggability.
CSS #
We already covered CSS optimization by introducing the cssnano plugin for PostCSS. It has a lot of options; we used it with the default preset.
Images #
This one was also covered. Since we are using url-loader to include images in the final output, we can experiment with the limit option to inline them or include separate image files. If additional image optimization is required, there are plugins that can compress images during webpack bundling.
Code elimination #
By “code elimination” we mean removing code that isn’t used in the final bundle. Why might it be unused? Over time, application code gets more complex and the number of modules exporting functionality that is no longer imported (or was never imported) increases. These modules could be part of the source code or a third‑party dependency. In any case, this code adds weight without value. Since webpack v4, the problem is handled automatically: in production mode, unused exports are eliminated and the code is minified (tree shaking). But we can optimize even more.
Welcome to Terser.
Terser does a few things to JavaScript. It parses and compresses it, along with optional mangling. This gives us more control over the final output, hence more optimization options. Let's change the default webpack optimizer to Terser and configure it.
webpack/webpack.config.ts #
Change webpack/webpack.config.ts to add minimizer option:
optimization: {
//...
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
ecma: 5,
inline: true,
warnings: false,
},
keep_classnames: true,
keep_fnames: true,
parse: {
ecma: 8,
},
output: {
ecma: 5,
comments: false,
},
},
sourceMap: true,
})
]
}
terser as well as the library it uses internally (uglify-js) is not using browserlist settings. We have to explicitly tell it what version of JavaScript to minify and what to skip. When specifying ecma 5, terser will keep it as is (no modifications). It will not convert ES6+ code to ES5. Tweak the settings for the browser support needed.
compress- customize compression optionsecma: 5- keep ecma 5 code (no modifications applied)inline: true- inline functions with arguments and variableswarnings: false- do not display warnings when dropping unreachable code or unused declarationskeep_classnames: true- prevent discarding class names (useful for debugging)keep_fnames: true- prevent discarding function names (useful for debugging)parse- customize parsing optionsecma: 8- the code that Terser parses represents the ECMAScript 2017 (ES8) specificationoutput- customize output optionsecma: 5- keep ecma 5 code (no modifications applied)comments: false- omit comments in the output
Not all modules in the project are safe to remove. One example is a module that exports functionality and also performs additional actions not related to the export (side effects). Another is modules that have no exports but must be imported. For these cases, webpack provides a sideEffects setting to identify modules that have side effects. Specifically for our setup, modules with side effects include those containing CSS code. We include them in the bundle by importing them by name, but we don’t use them directly inside JavaScript. This gives Terser the impression that these modules aren’t needed, so it removes them. To prevent this, mark CSS modules as having side effects.
webpack/webpack.config.ts #
Change webpack/webpack.config.ts:
//...
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
sideEffects: true
//...
test: /\.pcss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
sideEffects: true
//...
Caching (optional) #
The webpack bundling process can be cached as well. This way, only parts of the bundle affected by code changes are rebuilt. Usually, this optimization is performed by saving intermediate bundling results to disk for reuse in subsequent builds. As a direct result, quicker build times can be achieved. The following is a list of plugins that help with this optimization.
hard-source-webpack-plugin- https://www.npmjs.com/package/hard-source-webpack-plugincache-loader- https://www.npmjs.com/package/cache-loader
hard-source-webpack-plugin caches the entire process, where cache-loader could be optionally included to save specific loader results.