Skip to main content

Bundle optimization

Usually, the amount of a website assets grows with time. With more business features comes more functionality, which means more JavaScript, CSS, images, fonts, etc. We already touched upon optimizing assets in the previous section by caching less frequently changing content. Now, it is time to talk about optimizing assets themselves. By optimizing we are going to assume size minimization or build time minimization with no 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, babel loader is used extensively. JavaScript transformations performed by the babel could be optimized for time execution minimization by caching the result of the previous computations.

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 also 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 it is needed. This minimizes the overall bundle size.

Vendors

For now, all the third party JavaScript code that is used by out webpack setup is combined within one chunk file - vendors. From the caching point of view, if no changes were introduced to the third party code, no client needs to download vendors chunk. Good start, but we can take this idea to the next step. Instead of making a split between vendors and non-vendors code, we can split vendors chunk into multiple chunks, one per each specific vendor. Yes, we are going to have a lot more chunks, but the size of every chunk will be significantly smaller and clients will be downloading only chunks that have changes in them. This optimization requires a different thinking about how browsers download / cache assets and what happens during the page loading / rendering life-cycle.

Since the introduction of the HTTP/2.0 protocol, head-of-line blocking issue was resolved in favour of multiplexing requests - responses delivery. That means, more resources could be downloaded in parallel. Adding to that the mechanism of message compression before being sent through the wire instead of the HTTP/1.1 plain text delivery style, we get a strong confidence in multiple small files optimization techniques. And, of cause, every optimization is a subject to the web application that is using it and should be checked empirically for the real practical value. In other words, if a single vendor chunk works for you, splitting it into multiples might be an overkill.

webpack/webpack.config.dev.ts

Change webpack/parts.ts to exclude 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 are keeping single vendor chunk, but only for the development mode to keep bundling process 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 the rest of the plugins to optimize their work
  • runtimeChunk - Creates a separate chunk for the webpack runtime code itself
  • splitChunks - Reduces code duplication by finding modules shared between chunks and splits them into separate chunks
  • chunks: all - Pick specific chunks for shared module inspection (in out 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 chunks
  • vendors - Cache group name
  • test - Regex to determine file paths of filles under inspection. In this case anything that has node_modules in the path
  • name(module) - Function generates a name of the chunk

name function is the most important piece of the puzzle. First, it extracts package name out of the package path, assuming it follows node_modules folder. Second, it returns formatted chunk name starting with the vendor keyword. This way, every npm package is assigned a separate chunk and as such a separate file (no matter how big or small it is).

name function could return identical values for different npm packages. It this case, all the affected packages will be moved to the same chunk. This gives the ability of extra optimization by glueing packages if needed.

Sourcemaps

Using cheap value to generate version of the sourcemap files helps development flow. For production configuration we still use source-map. But for the development, there are plenty of options available. Pick the one that gives the best compromise between bundling speed and the quality of debug-ability.

CSS

We already covered CSS optimization by introducing cssnano plugin for PostCSS. It has lost of options and we introduced it with the default preset.

Images

This one was also covered. Since we are using url-loader to include images into the final destination, we could experiment we 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 are going to assume the ability to remove the code that is not used in the final bundle. Why is it not used? With time, application code is getting more complex and the number of modules exporting functionality that is not imported anymore (or have never been imported) is increasing. This modules could be part of the source code or a 3rd part dependency. In any case, this is the code that is adding extra weight and not bringing any value to the final deployable application. Since webpack version 4 the above described problem is handled automatically. In production mode, unused exports are eliminated and the code itself 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 some mangling options. This gives us way more control over the final output, hence more options for optimization. Let's change 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 options
  • ecma: 5 - keep ecma 5 code (no modifications applied)
  • inline: true - inline functions with arguments and variables
  • warnings: false - do not display warnings when dropping unreachable code or unused declaration
  • keep_classnames: true - prevent discarding class names (useful for debugging)
  • keep_fnames: true - prevent discarding function names (useful for debugging)
  • parse - customize parsing options
  • ecma: 8 - the code that terser parses represents ecma 8 specification
  • output - customize output options
  • ecma: 5 - keep ecma 5 code (no modifications applied)
  • comments: false - omit comments in the output

Not all the modules in the project are safe to be removed. A good example of such a module is the one that exports some functionality and performs some additional actions not related to the export (side effects). Another one, modules that do not have any exports but have to be imported. For all of these special cases, webpack provides a setting to be used to identify modules that have side effects, meaning behave in a way described above. Specifically to our set up, modules with side effects are those containing CSS code. We include them into the bundle by importing them by name. But at the same time, we are not using them directly inside JavaScript code. This give terser an impression that these modules are not needed, so it removes them from the final bundle. To prevent this behavior, we need to mark all the CSS modules as modules with 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)

Webpack bundling process could be cached as well. This way, only parts of the bundling that changed as a result of the code changes will get affected. Usually, this optimization is performed by saving intermediate bundling results to the disk for later reuse in subsequent builds. As a direc result, quicker built times could be achieved. The following is a list of plugins that help with this type of optimization.

hard-source-webpack-plugin caches the entire process, where cache-loader could be optionally included to save specific loader results.

Final version

Reference implementation