Optimizing your webpack builds

Angel M De Miguel

Angel M De Miguel


Open Source rocks! UX Engineer at @bitnami


Share this article

Webpack is the new must-have tool for Frontend development. Frameworks like Angular, React and VueJS use it in their examples and even in their CLIs. But, what's the secret of Webpack? It offers a lot of common and complex features through a single configuration file. Some of them are:

  • Hot module reloading for development
  • "Compilation" of the assets
  • Processing files with different loaders based on the file extension
  • Plugins
  • Optimization for production environments

However, the configuration file is both the solution and the most painful aspect of Webpack. Changing one line of the file may break your compilation process and the debug information isn't often too useful. If we combine this with the hundreds of NPM libraries we need to add to our project, we usually get a long compilation process. And you know, time is money.

Find the problem

This is the first step to optimize our Webpack build process. Webpack provides a good tool to analyze the profile of our build. We can get this profile using the Webpack CLI.

webpack --config webpack.config.js --profile --json > profile.json

Now, upload the profile.json file to http://webpack.github.io/analyse/ and see the results.

Result of the analysis of my profile

Before optimizing it, my build process spent ~20s and compiled 944 modules into 1 chunk file. That means Webpack has to process 944 modules in every compilation. But, where do all those modules come from? Let's move to the modules section to see more details.

Modules graph

This graph represents the relationships between the required modules and the source code. As I mentioned, Webpack needs to process this whole tree to build our project now. In this mess, we can find some libraries with a huge number of related dependencies. You can identify them because they are like black holes, they attract a lot of nodes to them.

Modules graph

What do you think those nodes are? In my example, the black holes are:

In most cases, those nodes are the entrypoints of the libraries you are using in the project. So, why we need to include them in our build process if they don't change? That's a key point to improving our builds, we don't need to recompile the libraries.

Extract libraries from the project build

Webpack Dynamic Linked Library plugins are the solution to our problem. These plugins allow us to extract the libraries that rarely change and reference them in our project instead of building them every time. To accomplish it, we should configure and use two plugins:

  • DllPlugin: Export all required libraries in the entrypoint as a single bundle javascript file. Also, it exports a private manifest file with the references of the modules in the bundle.
  • DllReferencePlugin: It loads the libraries in the manifest file from DllPlugin and references them in our source code.

As you may have noticed, now we are going to split our build process in two steps:

  1. Dll build: Bundle all external libraries. We only need to do it every time we include or modify an external library.
  2. Project build: Build the source code of the project, referencing the dynamic linked libraries.

Adding or modifying external libraries is not a common action, we will only need to rebuild them a few times. However, you build your project code a lot, so removing the libraries from the project build is a win!

Create the Dll bundle

The first thing we should do is to create a file that references all the libraries of the project. We can call it vendor.js and require there all libraries:

require('classnames');
require('immutable');
require('lodash');
require('lodash/array');
require('react');
require('react-dom');
require('react-bootstrap');
require('react-fontawesome');
require('react-notification');
require('react-redux');
require('react-router');
require('react-router-redux');
require('redux');
require('redux-thunk');

Build the libraries

To build the bundle, we create a new webpack.dll.js file with the following configuration. Remember to customize the paths for your environment:

var path = require('path'),
  webpack = require('webpack'),
  // Bind join to the current path. You can change it:
  // path.join.bind(path, __dirname, 'app').
  join = path.join.bind(path, __dirname);

module.exports = {
  entry: {
    // The entrypoint is our vendor file
    vendor: [ join('vendor', 'vendor.js') ]
  },
  output: {
    // The bundle file
    path: join('build'),
    filename: '[name].js',
    library: '[name]'
  },
  plugins: [
    new webpack.DllPlugin({
      // The manifest we will use to reference the libraries
      path: join('vendor', '[name]-manifest.json'),
      name: '[name]',
      context: join('vendor')
    })
  ],
  resolve: {
    root: join('vendor'),
    modulesDirectories: ['node_modules']
  }
}

Now, just run Webpack with this configuration file.

webpack --config webpack.dll.js

With this configuration, Webpack will create the two files we need for our project:

  • build/vendor.js
  • vendor/vendor-manifest.json

We will build the libraries only the first time or when some of them have changed.

Reference it in project build

After creating the bundle, we should reference it in our project. This is as simple as adding a new plugin into the plugins array of our webpack.config.js file:

var path = require('path'),
  webpack = require('webpack'),
  // Bind join to the current path. You can change it:
  // path.join.bind(path, __dirname, 'app').
  join = path.join.bind(path, __dirname);

module.exports = {
  // Your config...
  plugins: [
    // Your plugins...
    new webpack.DllReferencePlugin({
      // An absolute path of your application source code
      context: path.join(__dirname, "app"),
      // The path to the generated vendor-manifest file
      manifest: require(path.join(__dirname, "./vendor/vendor-manifest.json"))
    }),
  ]
}

This plugin only loads the references, but we must include the vendor.js file in the generated HTML of our project.

<script src="vendor.js"></script>

Compare results

This is the analysis with our new awesome Webpack configuration.

Modules graph

We have reduced the build time from ~20s to ~5s and the number of modules from 944 to 220. This optimization affects hot rebuilds too. Before the optimization, Webpack was taking 4-5s to rebuild the source code after a change. Now, it only takes 250-500ms!

Modules graph

As you can see, the modules graph doesn't have any black holes now.

Other tips to improve the performance

Extracting libraries from the project build is the most important optimization. Nevertheless, there are other good practices to improve the build/rebuild time.

Prefetching

The Webpack analysis tool has a section called Hints. If you navigate to this section, you may read that you can "use prefetching to increase build performance". Prefetch is another Webpack plugin. It preloads files before starting the build. So, we can add project modules with a lot of dependencies before starting the build. Add a PrefetchPlugin for each one of them to the plugins array of your application:

var path = require('path'),
  webpack = require('webpack'),
  // Bind join to the current path. You can change it:
  // path.join.bind(path, __dirname, 'app').
  join = path.join.bind(path, __dirname);

module.exports = {
  // Your config...
  plugins: [
    // Your plugins...
    new webpack.PrefetchPlugin(join('app/containers/MyBigComponent.jsx')),
    new webpack.PrefetchPlugin(join('app/containers/AnotherBigComponent.jsx'))
    // ...
  ]
}

Improve loaders

Loaders are responsible for processing the files of our project. This is a typical loader for React source code, but it's not really optimized.

{
  test: /\.(js|jsx)?$/,
  exclude: /(node_modules|bower_components)/,
  loader: 'babel-loader'
}

The exclude property is removing the libraries from the scope, but other files of your project may be processed. You can reduce the scope only to the folder of your source code using include:

{
  test: /\.(js|jsx)?$/,
  include: [ path.join(__dirname, './app') ],
  loader: 'babel-loader'
}

Some loaders accept parameters. babel-loader has a parameter to activate the cache of the directory and it improves the build performance:

{
  test: /\.(js|jsx)?$/,
  include: [ path.join(__dirname, './app') ],
  loader: 'babel-loader',
  query: {
    // Improve performance
    cacheDirectory: true
  }
}

Source maps

Source maps can be very heavy and expensive to create, but we need them for debugging in development. Webpack has several approaches to generate these files. For development, I recommend you use cheap-module-eval-source-map, because it's very fast and it maps the generated code with the source code. Other source map strategies don't provide the original source code. There is a good comparison from the Webpack documentation.

Modules graph

Summary

Optimizing Webpack is not trivial. I spent a few hours reading articles and documentation. But, it's important for developers to have a properly environment. Before the optimization, we wasted minutes waiting for the build processes. Now, we can develop the project faster.

The developer experience is part of the project

References