In this article, I'm going to show you how we went from 30 requests for 3.1MB of minified uncompressed JavaScript to 19 requests for 2.2MB of minified uncompressed JS. Why uncompressed? Because Rails doesn't gzip on localhost, and our production servers are running the new setup already.

We weren't doing anything stupid before. Tree shaking, minification, code splitting to avoid JavaScript users don't need… we had all of that. We even split our frontend into 5 or 6 discreet apps, each with its own internal code splitting.

And yet, 30 requests for 3.1MB.

Our problem: Third-party libraries. There's a bunch of stuff that we need, like Backbone and Handlebars and Lodash and jQuery and so on. Most of them we loaded from public CDN. Some of them we bundled locally into a vendor.js file; this is where the huge size came from.

You see, when all your apps share a vendor.js file, and some of them are React and some are Backbone, guess what happens? All apps load both React and Backbone.

We achieved this vendor/app split following Webpack's official guide on code splitting libraries. It suggests using the CommonChunksPlugin to extract common code into a top-level file.

    plugins: [
        // Avoid publishing files when compilation failed:
        new webpack.NoEmitOnErrorsPlugin(),
        new ExtractTextPlugin( envConfig.inDevelopment() ?  "[name]_style.css" : "[name]_style.[chunkhash].css"),
        new webpack.optimize.CommonsChunkPlugin({
            names: ['vendor', 'manifest'],
        })
    ]

manifest.js is meant to contain Webpack's runtime, and vendor.js contains your more stable third-party dependencies. This is meant to improve caching. shrug

On top of that, we had a bunch of global libraries configured as externals and loaded them in top-level <script> tags. Not a bad approach per se, but something like the AWS SDK is almost 500kb of compressed JavaScript. When you're using only one function… yeah, no bueno.

We fixed this situation with a 2-pronged approach:

  • bundle and tree shake all our dependencies ourselves
  • create a different vendor file for each app (entry file)

Here's how that looks:

const Apps = {
    // list entries
    // will be reused as Webpack's entry config
};
 
// previously loaded as externals
const GlobalModules = _.map({
    jquery: ['$', 'jQuery', 'window.jQuery'],
    lodash: ['_'],
    backbone: ['Backbone'],
    'backbone-validation': ['Backbone.Validation'],
    'raven-js': ['Raven'],
    moment: ['moment'],
    string: ['string', 'S'],
    async: ['async']
}, (vars, module) => new webpack.ProvidePlugin(
    _.fromPairs(vars.map(v => [v, module]))
));
 
// updated plugins config
    plugins: [
        new webpack.NoEmitOnErrorsPlugin(),
        new ExtractTextPlugin( envConfig.inDevelopment() ? "[name]_style.css"
                                                         : "[name]_style.[chunkhash].css"
        ),
    ].concat(
        GlobalModules
    ).concat(
        Object.keys(Apps)
              .map(app => new webpack.optimize.CommonsChunkPlugin({
                  name: `${app}_vendor`,
                  chunks: [app],
                  minChunks: isVendor
              }))
    ).concat([
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest',
            chunks: Object.keys(Apps).map(n => `${n}_vendor`),
            minChunks: (module, count) => {
                return count >= Object.keys(Apps).length && isVendor(module)
            }
        })
    ])

All our entry files, or apps as we call them, go in Apps. This helps us iterate over them when building the plugins config.

What used to be externals loaded in global script tags become GlobalModules. Each is translated into a ProvidePlugin configuration, which essentially replaces all occurrences of $ with require('jquery'), moment with require('moment'), and so on.

With this approach, we don’t have to change any code that relies on global libs’ availability.

Armed with these vars, we dynamically generate a list of Webpack plugins. Each app gets its own vendor file – _vendor.js – and at the end, all files together get a common manifest file with the Webpack runtime. Again, to prevent cache churning.

Oh, and there’s the helpful isVendor function, which I got from Juho’s SurviveJS – Webpack book. You should buy it. It’s great.

function isVendor(module, count) {
    const userRequest = module.userRequest;
 
    return userRequest && userRequest.indexOf('node_modules') >= 0;
}

This function tells us if a specific module is a third-party library or our own code. It's third party if it's in node_modules, and our own code if it's not.

I hope this helps make your website faster. It got our page speed score from 54 to 95 ?

PS: This article is a spiritual successor to Migrating to Webpack 2: some tips and gotchas.

Learned something new? 💌

Join 8,400+ people becoming better Frontend Engineers!

Here's the deal: leave your email and I'll send you an Interactive ES6 Cheatsheet 📖 right away.  After that you'll get an email once a week with my writings about React, JavaScript,  and life as an engineer.


You should follow me on twitter, here.