Put Your Webpack Bundle On A Diet - Part 3

So we’ve performed an initial analysis and explored the use of webpack -p to bring our bundle size down from 1.7MB to 640KB. Then, we learned how to reduce that an additional 80KB by supplying our own configuration. But what comes next?

Part three will tackle more detailed optimizations related to some common modules, like Moment.js, Babel, and Lodash.

In this article, we’ll cover how to: - Remove locales when utilizing Moment.js - Implement the Date-fns library as a slimmer alternative to Moment.js - Only transpile what you need to with babel-preset-env - Avoid code duplication with Lodash

Cease the Moment.js

Moment.js is a library that helps you parse, validate, manipulate, as well as display dates and times in JavaScript.

The library supports many locales by default. This is great—but because it’s available in many languages, its payload is rather big. Fortunately, we can fix that. We’ll start by using the webpack IgnorePlugin in your webpack.config.js file to remove any unwanted leftovers from your bundle.

If you want to strip out all locales, you can do this by adding the following config:

1
new Webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)

In some cases, you may need a couple of locales, but not all of them. Regex can help us out here too. Here’s an example if we only want English and German:

1
new webpack.IgnorePlugin(/^\.\/locale\/(en|de)\.js$/, /moment$/)

You can also achieve the same results using the ContextReplacementPlugin. Let’s take the same example, but specify which variants of the German language we want.

With all variants, including standard German (de) and the Austrian dialect (de-at):

1
new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en|de/)

Without variants (only de):

1
new Webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /(en|de)(?!-)/)

The technique used above can be recycled for other dependencies and files that we want to keep out of our optimized bundle.

Here’s a look at how our optimized webpack bundle measures up after removing the Moment.js locales:

table-momentjs

Webpack comparison Stat size Parsed size Gzipped size Webpack on a dies part II -image3 First paint on 3G
and low-end mobile
Webpack v3.6
build time
intentionally unoptimized 1.69MB 1.76MB 399.17KB ~ 3292 ms ⌀ 6.2 s
webpack -p 1.65MB 640.45KB 208.79KB ~ 2276 ms ⌀ 7.9 s
manually optimized 1.56MB 564.25KB 166.39KB ~ 2240 ms ⌀ 10.9 s
CMA with modules syntax 1.51MB 558.71KB 165.48KB ~ 2200 ms ⌀ 11.2 s
without Moment.js locales 1.19MB 398.78KB 121.71KB ~ 1948 ms ⌀ 10.1 s

You can find the actual commit on GitHub.

This is already an improvement, but the Moment.js module is still too heavy considering we only need one specific date format for our app. Many cases only use Moment.js for very simple date manipulation or formatting tasks. And since Moment.js does not support tree shaking yet, we need to implement another library: date-fns.

Replacing Moment.js with Date-fns

Date-fns describes itself as, ”the most comprehensive, yet simple and consistent toolset for manipulating JavaScript dates in a browser and Node.js.”

Date-fns is similar to Moment.js in that they have a lot of overlapping functionality. But while Moment.js exposes one big object that can handle everything, date-fns is built for a more functional approach. Additionally, date-fns supports tree shaking when used together with babel-plugin-date-fns, a Babel plugin that replaces generic date-fns imports with specific ones.

As you can see below, utilizing date-fns in conjunction with this plugin will help trim down your bundle size and speed up your build time:

table-datefns

Webpack comparison Stat size Parsed size Gzipped size Webpack on a dies part II -image3 First paint on 3G
and low-end mobile
Webpack v3.6
build time
intentionally unoptimized 1.69MB 1.76MB 399.17KB ~ 3292 ms ⌀ 6.2 s
webpack -p 1.65MB 640.45KB 208.79KB ~ 2276 ms ⌀ 7.9 s
manually optimized 1.56MB 564.25KB 166.39KB ~ 2240 ms ⌀ 10.9 s
CMA with modules syntax 1.51MB 558.71KB 165.48KB ~ 2200 ms ⌀ 11.2 s
without Moment.js locales 1.19MB 398.78KB 121.71KB ~ 1948 ms ⌀ 10.1 s
date-fns instead of Moment.js 1.09MB 354.23KB 107.27KB ~ 1902 ms ⌀ 9.8 s

Check out the commit for this improvement here.

Only transpile what you need to with babel-preset-env

Now that we’ve chosen a lean toolset for configuring dates, we can find other areas of our bundle to reduce. For instance, in its unoptimized state, our example app uses babel-preset-es2015 which was recently deprecated. This means that we must use another solution—the babel-preset-env package.

The babel-preset-env package is a Babel preset that compiles ES2015+, used in our unoptimized app, down to ES5 by “automatically determining the plugins and polyfills you need based on your targeted browser or runtime environments.”

The configuration for the plugin should be in the .babelrc file and look something like:

1
2
3
4
5
6
7
8
9
{
  "presets": [
    ["env", {
      "targets": {
        "browsers": ["last 2 versions", "safari > 8", "not ie < 11"]
      }
    }]
  ]
}

Something to note is the targets.browsers property. This is where you can set criteria for the included Babel plugins. Each plugin from the latest preset can be included if necessary, such as es2015, es2016, and es2017.

To get a preview of what browsers your configuration includes, you can use browserl.ist. Pass your browser criteria through as a list separated by commas, and the listed browsers will be included in the config file. You can find a query syntax spec on the browserlist repository.

What you get with babel-preset-env alone:

Webpack comparison Stat size Parsed size Gzipped size Webpack on a dies part II -image3 First paint on 3G
and low-end mobile
Webpack v3.6
build time
intentionally unoptimized 1.69MB 1.76MB 399.17KB ~ 3292 ms ⌀ 6.2 s
webpack -p 1.65MB 640.45KB 208.79KB ~ 2276 ms ⌀ 7.9 s
manually optimized 1.56MB 564.25KB 166.39KB ~ 2240 ms ⌀ 10.9 s
CMA with modules syntax 1.51MB 558.71KB 165.48KB ~ 2200 ms ⌀ 11.2 s
without Moment.js locales 1.19MB 398.78KB 121.71KB ~ 1948 ms ⌀ 10.1 s
date-fns instead of Moment.js 1.09MB 354.23KB 107.27KB ~ 1902 ms ⌀ 9.8 s
babel-preset-env 1.09MB 354.23KB 107.27KB - ⌀ 9.8 s

See this commit on GitHub.

So the optimization efforts above didn’t help… but why?

It’s because there is a common misconception that babel-preset-env v1 excludes polyfills. But in reality, your import of babel-polyfill is not touched at all in version one. The upcoming version two, however, will finally be able to exclude polyfills. To make this work, we have to upgrade to Babel v7.

First, run these commands:

1
npm i -D babel-cli@next babel-core@next babel-preset-env@next babel-polyfill@next

Then enable the useBuiltIns flag in the .babelrc file:

1
2
3
4
5
6
7
8
9
10
{
  "presets": [
    ["env", {
      "useBuiltIns": true,
      "targets": {
        "browsers": ["last 2 versions", "safari > 8", "not ie < 11"]
      }
    }]
  ]
}

Hint: Do not include babel-polyfill via an webpack entry. Instead, you should have it as an import statement at the beginning of the entry code file of your app.

Let’s take a look at our bundle size now:

table-babelpreset

Webpack comparison Stat size Parsed size Gzipped size Webpack on a dies part II -image3 First paint on 3G
and low-end mobile
Webpack v3.6
build time
intentionally unoptimized 1.69MB 1.76MB 399.17KB ~ 3292 ms ⌀ 6.2 s
webpack -p 1.65MB 640.45KB 208.79KB ~ 2276 ms ⌀ 7.9 s
manually optimized 1.56MB 564.25KB 166.39KB ~ 2240 ms ⌀ 10.9 s
CMA with modules syntax 1.51MB 558.71KB 165.48KB ~ 2200 ms ⌀ 11.2 s
without Moment.js locales 1.19MB 398.78KB 121.71KB ~ 1948 ms ⌀ 10.1 s
date-fns instead of Moment.js 1.09MB 354.23KB 107.27KB ~ 1902 ms ⌀ 9.8 s
babel-preset-env v2 1.04MB 332.4KB 100.23KB ~ 1820 ms ⌀ 9.8 s

Find the actual commit for this improvement here.

We’ve almost reached the 100KB mark for our gzipped size, but we are not done yet—we can still squeeze more out of the bundle.

Avoiding code duplication with Lodash

Lodash is a JavaScript utility library that claims to deliver modularity and performance. It is currently available as lodash, lodash-es, lodash-amd, and about 286 other module variants, which only contain one method of Lodash.

Your dependencies might also depend on other versions of Lodash. This can result in a lot of code duplication because any of these dependencies might use different export of Lodash. To prevent this, we can take the following steps:

Step 1: Transform generic Lodash requires to cherry-picked ones

This can be achieved using babel-plugin-lodash and can help to decrease the bundle size.

The following should be added to your .babelrc file:

1
2
3
4
5
{
  "plugins": [
    "lodash"
  ]
}

Step 2: Remove unnecessary lodash functionality

We can do this using the lodash-webpack-plugin which works great in combination with babel-plugin-lodash.

Our contentful.js SDK currently requires these Lodash features:

1
2
3
4
5
new LodashModuleReplacementPlugin({
  caching: true,
  cloning: true,
  memoizing: true
})

Additionally, our contentful-management.js SDK needs the following:

1
2
3
4
new LodashModuleReplacementPlugin({
  caching: true,
  cloning: true
})

Just keep in mind that your other dependencies still might need some of these features.

Step 3: Avoid Lodash variant duplication

First, identify all the lodash variants in your production dependencies:

1
2
3
4
5
6
7
8
9
$ npm list --prod | grep "lodash\(\.\|-es\|-amd\)" | grep -v "deduped"
│ ├── lodash-es@4.17.4
│ ├── lodash.isfunction@3.0.8
│ ├── lodash.isobject@3.0.2
│   └── lodash.merge@4.6.0
│ ├── lodash.get@4.4.2
│ ├── lodash.reduce@4.6.0
│ ├── lodash.set@4.3.2
│ └── lodash.unset@4.5.2

Now create webpack resolve aliases for every package in your dependency tree. To do this, alias them to the cherry-picked version of the basic Lodash package.

Here is an example of what your webpack config can look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const webpackConfig = {
  resolve: {
    alias: {
      'lodash-es': 'lodash', // our internal tests showed that lodash is a little bit smaller as lodash-es
      'lodash.get': 'lodash/get',
      'lodash.isfunction': 'lodash/isFunction',
      'lodash.isobject': 'lodash/isObject',
      'lodash.merge': 'lodash/merge',
      'lodash.reduce': 'lodash/reduce',
      'lodash.set': 'lodash/set',
      'lodash.unset': 'lodash/unset'
    }
  }
}

And let’s check our bundle:

table-lodash

Webpack comparison Stat size Parsed size Gzipped size Webpack on a dies part II -image3 First paint on 3G
and low-end mobile
Webpack v3.6
build time
intentionally unoptimized 1.69MB 1.76MB 399.17KB ~ 3292 ms ⌀ 6.2 s
webpack -p 1.65MB 640.45KB 208.79KB ~ 2276 ms ⌀ 7.9 s
manually optimized 1.56MB 564.25KB 166.39KB ~ 2240 ms ⌀ 10.9 s
CMA with modules syntax 1.51MB 558.71KB 165.48KB ~ 2200 ms ⌀ 11.2 s
without Moment.js locales 1.19MB 398.78KB 121.71KB ~ 1948 ms ⌀ 10.1 s
date-fns instead of Moment.js 1.09MB 354.23KB 107.27KB ~ 1902 ms ⌀ 9.8 s
babel-preset-env v2 1.04MB 332.4KB 100.23KB ~ 1820 ms ⌀ 9.8 s
avoiding lodash duplication 841.94KB 287.42KB 89.64KB ~ 1708 ms ⌀ 9.0 s

Check out this commit on GitHub.

The pure minified file is now below 300KB and when gzipped, it will send less than 100KB over the net. That’s pretty impressive—but there's always room for improvement. If anyone has any further tricks, start a discussion on our community forum.

What's Next

We've made significant improvements since our original 1.7MB bundle size. We could stop here, but what fun would that be?

In the next and last article of this series, we're aiming to hit webpack's recommended 250KB parsed size. We'll accomplish this by introducing chunk-splitting to enable better cacheability of your app over time and to only send relevant code to the user. Check back on the Contentful Blog next week or follow us on Twitter to stay updated.

Blog posts in your inbox

Subscribe to receive most important updates. We send emails once a month.