I try to help

Webpack optimisations and tips

Performance

Approximate reading time: 6 minute(s)

Webpack is, at the time of writing, the most used bundler. I am aware of other tools such as Snowpack, Vite and, Parcel. I find them compelling, but most of my projects use Webpack at the moment.

I'm going to assume you have a basic understanding of how Webpack works and have a working config. I suggest reading the official docs before continuing. Please adjust these examples to suit your needs. Use these as a jumping off point.

When benchmarking always measure and keep track of progress.

Speed Measure Plugin

Speed Measure Plugin measures your Webpack build speed, its output looks something like this:

 SMP  ⏱  
General output time took 5.56 secs

 SMP  ⏱  Plugins
TerserPlugin took 3.39 secs
HtmlWebpackPlugin took 0.108 secs

 SMP  ⏱  Loaders
modules with no loaders took 1.21 secs
  module count = 632
babel-loader took 0.411 secs
  module count = 1
html-webpack-plugin took 0.01 secs
  module count = 1

Webpack Bundle Analyzer

Webpack Bundle Analyzer will help us visualize the output files. Let's take a look at the output:

Webpack Bundle Analyzer Example

While I don't enable this tool all the time, I have gotten used to running this every so often.

We can see large chunks as well as any anomalies in the resulting bundle. A picture is worth a thousand words.

Bundle Stats

Bundle Stats generates bundle reports and we can use these to compare results between different builds. We can use this tool to spot regressions and see if our changes have caused any difference in size.

Performance Budget

We can set a performance budget to warn, or stop the build when assets are larger than a specified size:

// webpack.config.js
module.exports = {
  // your config
  performance: {
    hints: 'error'; // This will error rather than show a warning, causing the build to fail.
    maxEntrypointSize: 500000, // The entrypoint can be, at most, 500Kb
    maxAssetSize: 300000, // Individual assets can be, at most, 300Kb
    assetFilter: (assetFileName) => { 
      return assetFileName.endsWith('.js'); // Show hints only for .js files
    }
  }
}

Better Stats

The stats section of a Webpack config contains a lot of useful information, but sometimes they might be a bit too overwhelming. For personal projects I prefer using:

// webpack.config.js
module.exports = {
  //...
  stats: {
    assetsSort: 'size',
    children: false,
    chunksSort: 'size',
    excludeAssets: /.js.map/,
    modules: false
  }
}

Webpack has a plethora of configuration options and stats are no different.

Splitting app code into chunks

Doing this is as simple as setting the splitChunks option above. I recommend starting with the simplest example and working from there:

// webpack.config.js
module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

I would love to give more tips here, but they are highly specific. I would recommend you read the documentation on this topic.

Splitting the vendor chunk

I'm starting us off with a optimisation I don't see people talking about: splitting vendor into multiple chunks.

Webpack's documentation shows the following example:

// webpack.config.js
module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'vendor',
          chunks: 'all',
        }
      }
    }
  }
};

This is nice, but there's a problem with this example, it doesn't account for dependencies. If we want to include react-router it won't include the dependencies of react-router.

Let's fix this and extend it a bit.

First we'll write a function to determine if a module is part of the dependencies of another:

// webpack.config.js
function isDependency(module, target) {
  if (Array.isArray(target)) {
    return target.some(t => isDependency(module, t)); // This will make sense in the next snippet
  }

  // This is a naive check but it will do for now
  if (module.identifier().includes(name)) {
    return true;
  }

  if (module.issuer) {
    return isDependency(module.issuer, target);
  }

  return false;
}

Now in our config we can reference this function when writing our cacheGroups.

// webpack.config.js
module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        react: {
          test: module => isDependency(module, ['react', 'react-dom', 'react-router']),
          name: 'react',
          chunks: 'all',
        },
        lodash: {
          test: module => isDependency(module, 'lodash'), // We can specify either a single module, or an array
          name: 'lodash',
          chunks: 'all'
        }
      }
    }
  }
};

Now all three React-specific dependencies are in a single chunk.

Alternative to vendor splitting: externals

Why add dependencies to a bundle when I can import them from a CDN or a pre-built file? The externals configuration options provides a way to do this.

// index.html
<script src="https://unpkg.com/browse/react@17.0.2/umd/react.production.min.js"></script>
<script src="https://unpkg.com/browse/react-dom@17.0.2/umd/react-dom.production.min.js"></script>

// webpack.config.js
module.exports = {
  //...
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
  },
};

Alternative to vendor splitting: DllPlugin

DllPlugin allows us to reference an already built bundle. This drastically cuts back on build times both in development and in production.

Setting this up is a bit fiddly and an automated process would probably get more people to try this out.

First we'll setup a vendor Webpack config

// webpack.vendor.config.js
module.exports = {
    mode: 'production',
    entry: {
        vendor: ['react', 'react-dom']
    },  
    output: {
        filename: 'react_vendor.bundle.dll.js',
        path: path.join(__dirname, 'build'),
        library: 'react_vendor'
    },
    plugins: [
        new webpack.DllPlugin({
            name: 'react_vendor',
            path: path.join(__dirname, 'build', 'vendor-manifest.json')
        })
    ]
}
// index.html
<script src="vendor.bundle.dll.js" defer></script>
// webpack.config.js
module.exports = {
  //...
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: path.join(__dirname, 'build', 'vendor-manifest.json')
    }),
    //..
  ]
}

Cache

Another way to improve build times is by enabling a persistent cache.

// webpack.config.js
module.exports = {
 //...
 cache: {
   type: 'filesystem',
   buildDependencies: {
     config: [__filename],
   },
   cacheLocation: path.resolve(__dirname, '.build_cache'),
 },
}

Read the documentation of plugins and loaders! Some may have caches of their own.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          cacheCompression: false, // default is true. Disabling compression is faster
          cacheDirectory: true,
        },
      },
    ],
    plugins: [new ESLintPlugin({
      cache: true
    })],
  },
}

Thread-loader

Thread-loader runs the subsequent loaders in a worker pool. Simple!

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: [
          'thread-loader',
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env']
            }
          }
        ]
      },
    ]
  },
}

On a 12-core machine I've had good results by warming up thread-loader.

// webpack.config.js
const threadLoader = require('thread-loader');

threadLoader.warmup(
  {
    // pool options, like passed to loader options
    // must match loader options to boot the correct pool
  },
  [
    // modules to load
    // can be any module, i. e.
    'babel-loader',
  ]
);

JS Loaders

There are two interesting loaders I would like to highlight. These provide, in my experience, a large increase in speed. They are multi-threaded by default so these should not need thread-loader.

Be warned, these are still young and may have bugs which are difficult or frustrating to deal with.

swc loader

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: {
          loader: 'swc-loader',
          options: {
            jsc: {
              target: "es2015"
            }
          }
        }
      }
  }
}

and esbuild loader

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: {
          loader: 'esbuild-loader',
          options: {
            target: 'es2015'
          }
        }
      }
    ]
  }
}

Minifiers

Similar to the loaders mentioned above we can use swc and esbuild as minifiers. My experience has shown that these tools are faster than Terser.

// webpack.config.js
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          ecma: 2015
        },
        minify: TerserPlugin.swcMinify,
        // minify: TerserPlugin.esbuildMinify, // or esbuild
      })
    ],
  },
}

Closing Thoughts

A fast build, both in production and development matters! It improves developer experience, saves CI cpu cycles. Spend time improving your build script. Webpack is not a slow, lumbering giant. It's a tool which seems arcane to most, but is, in fact, quite accessible and flexible.