Analysing and minimising the size of client side bundle with webpack and source-map-explorer

TLDR;

  1. Use source-map-explorer to analyse your bundle dependencies.
  2. Watch out for server side modules bleeding into the client side causing unneeded polyfills to be bundled when building an isomorphic app.
  3. You may not need all of babel-polyfill, pick and chose the polyfills you need.
  4. Don’t install the whole library if it allows for modular installs.
  5. webpack alias is a powerful feature that can be used to minimise the bundle size.

Recently I have had the opportunity to take a browserify and gulp based project and convert it to webpack. And along with it also came the opportunity to look at and analyse the size of the client side bundle and see what extra fat could be cut off. What followed was interesting and the same time a bit extreme approach to cutting off the extra fat from the client side bundle. Some of the situations I ran into were also due to the fact that we were building an isomorphic app, so may not apply to everyone.

Analysing the client side bundle

To analyse the client side bundle, I initially started off with webpack-bundle-analyzer, although very visual and very easy to get setup, I found the UI hard to navigate around [although this might not be the case with the latest release]. At the same time I came across this tweet about source-map-explorer.

I was sold on source-map-explorer as soon as I saw how easy it was to navigate around the UI. One of the other benefit of source-map-explorer is also the fact that you don’t really need webpack to analyse your bundle. As long as you have a source map for your bundled file you are good to go.

I ran my analysis on the minified bundle of the app, as at the end of the day that is what get’s deployed to the end users browsers. With that in mind you can simply run an analysis on your minified file using the following commands

//Install source-map-explorer globally
$ npm install -g source-map-explorer 
$ source-map-explorer /path/to/bundle.js /path/to/bundle.js.map

This will automatically open up a browser for you with a chart showing you your client side bundles module dependencies. From then you can easily navigate around the UI and see the dependencies that are being pulled in.

Modular install of library

When starting with this process one of the most obvious bloat on our client site bundle was lodash, although we were careful of not pulling in the whole of lodash into the project, somehow it had made it to the codebase.

import { debounce } from 'lodash'

It was obvious that we only needed debounce from lodash, so I uninstalled lodash and installed lodash.debouce. Changed the import to:

import debouce from 'lodash.debounce'

This automatically brought the client side bundle size down by 50kB, which is quite significant drop in the bundle size for tiny bit of effort.

Using IgnorePlugin to ignore modules

After the obvious fix, I was seeing modules, which of were of no use on the client side. Modules like mv, rim-raf and source-map-support, for our web app just did not make any sense. Fortunately webpack comes with a handy IgnorePlugin which I used for this scenario to exclude these modules.

//webpack.config.js
{
...
plugins: [
new webpack.IgnorePlugin(/^(mv|rim-raf|source-map-support)$/)
]
...
}

This managed to shave off another 67kB from the bundle. I was still seeing polyfills for node core modules like http, stream and buffer on the client side bundle, which I was pretty certain we did not need. Trying to exclude these using the IgnorePlugin did not work. I parked these, to tackle them later on in the process.

Removing babel-polyfill

From then on the next big piece of chunk in the bundle I was seeing was babel-polyfill which came in at 74.2 kB for the minified bundle.

I knew that we were not using all of the ES6 features and very few ES7 features, plus there was an additional overhead of 5.69 kB for the regenerator-runtime module to support generator functions, which we were not using at all. After going through the babel docs I figured out that, you can actually pull in only the polyfills you are currently using or want to use using core-js. So instead of pulling in babel-polyfill into our app, I simply pulled in the dependencies that were needed for the app by creating a file called es6-polyfill.js as below.

//es6-polyfill.js
import 'core-js/es6/array'
import 'core-js/es6/function'
import 'core-js/es6/map'
import 'core-js/es6/math'
import 'core-js/es6/number'
import 'core-js/es6/object'
import 'core-js/es6/promise'
import 'core-js/es6/regexp'
import 'core-js/es6/string'
import 'core-js/fn/array/includes'

And simply pulled in this file as the first dependency on our client side entry file. This ended up shaving off 59kB from your client side bundle. Win Win. The above approach also get’s rid of es5-shims from babel-polyfill, which we did not need, as were targeting browsers that were IE10 and greater.

Getting rid of multiple promise polyfills

At this point I was quite into the process and looking at every possible way I could reduce any extra fat necessary off of the client side bundle. So this next one is a bit of adventurous one. We were using fetchr to make api calls on client side and server side. One thing I realised going through source-map-explorer was that fetchr bring’s it’s own implementation of promise with it i.e. es6-promise. However we were already polyfilling promise support with core-js for the app. At this point I wanted to experiment and see if I could only use one implementation of promise for the whole of the app including it’s dependencies. webpack’s alias feature came in pretty handy here.

//webpack.config.js
{
...
resolve: {
alias: {
'es6-promise$': path.resolve(__dirname, 'src/shims/shim-es6-promise.js')
}
}
...
}
//shim-es6-promise.js
module.exports = {
Promise: require('core-js/es6/promise')
}

What you are basically saying over here is, for everywhere, where a require(‘es6-promise’) is encountered, while bundling, use the file specified in the alias to resolve the dependency. This brought the bundle size down further by 7kB. Not a lot, but with alias I had discovered a more powerful tool, to tackle some of the problem I could not previously.

Getting rid of node core polyfills

If you remember I mentioned that I was still seeing polyfills for node core modules http, stream and buffer being bundled on the client side. And adding them to IgnorePlugin was not working.

After looking further into the dependency tree, I realised we had server specific modules like metrics and bunyan being bundled for the client side, as a result of the app being isomorphic. [Note: bunyan can be used on the client side, but we were not capturing any logs from bunyan on the client side, so was redundant]. As result of pulling these modules, webpack included polyfills for node core modules http, stream and buffer for the client side.

The approach I took here was to create my own shims for the bunyan and metrics and use webpack alias to resolve any calls to bunyan and metrics.

//webpack.config.js
{
...
resolve: {
alias: {
'es6-promise$': path.resolve(__dirname, 'src/shims/shim-es6-promise.js'),
'metrics': path.resolve(__driname, 'src/shims/shim-metrics.js'),
'bunyan': path.resolve(__dirname, 'src/shims/shim-bunyan.js')
}
}
...
}

What I did with the shims were basically mock the public api’s that were being called on these specific modules, just to ensure that they would not throw. For e.g my shim for metrics looks like:

//shim-metrics.js
function Report () {}
Report.prototype.addMetric = function () {}
Report.prototype.summary = function () {}
module.exports = {
Report: Report
}

The bunyan shim was more involved and I ended up mocking most of it’s public api. This probably comes with a maintenance overhead, so probably depends on the teams mileage. But for what it’s worth I managed to shave off 80kB off of the client side bundle using this technique. Plus I also came across this documentation on bunyan, for it’s usage with webpack, which does what I did with IgnorePlugin.

Using strip-loader

None of the client side logs were being captured in production, so there were was no need of having them in the minified bundle. I used strip-loader to get rid off all the log statements from the production build, to see how much it would help.

$ npm install --save-dev strip-loader
//webpack.config.js
{
...
module: {
loaders: [
{ test: /\.js$/, loader: WebpackStrip.loader('logger.debug', 'logger.info', 'console.log') }
]
}
...
}

Doing this stripped off another 6kB from the client side bundle.

Switching to Preact

The last thing I tried was to switch React to Preact. This was pretty easy to do again with webpack alias.

$ npm install -S preact preact-compat
//webpack.config.js
{
...
reslove: {
alias: {
'react': 'preact-compat',
'react-dom': 'preact-compat'
}
}
...
}

This shaved off 115kB from the bundle but I refrained from making this change as the underlying flux framework we used, exposed a component that relied on React.createClass which Preact currently does not support. Plus this would also probably have meant a full swoop or round of testing to feel confident with the build. But Preact is definitely an alternative I would consider for it’s size, going forward.

Conclusion

Overall I managed to shave off 359kB from the minified bundle just by manually analysing what was being put in the bundle. If building an isomorphic app there could be chances that server specific modules could be leaking into the client side and hence unneeded polyfills for node core modules could be being bundled for the client side. Plus babel-polyfill seems to bring a lot that you may not need, so pulling in only what is needed seems to help a lot. Analyse and minimise.