Case study: improving a popular library’s size for webpack users

There’s a library called Polished. It’s a utility collection for writing styles in JavaScript.

The polished logo

And it had a problem.

Problem#

A story in three tweets:

So, this code:

import { opacify, transparentize } from 'polished'; 

generates a much larger bundle than this code:

import opacify from 'polished/lib/color/opacify.js';
import transparentize from 'polished/lib/color/transparentize.js';

even despite the Polished’s bundle is built with ES modules and tree-shaking is enabled.

Let’s find out what causes this.

Investigation#

1. Verify the entry point#

Environment: polished@1.2.0 and webpack@2.6.1 (webpack@3.0.0 gives the same result)

At first, let’s check that import { ... } from 'polished' picks up a file written with ES exports. If it doesn’t, webpack can’t do any tree-shaking at all.

When you import a package, webpack understands what exact file to use by looking into specific fields in package.json. Polished’s package.json has two of them:

{
  "name": "polished",
  "description": "A lightweight toolset for writing styles in Javascript.",
  "main": "lib/index.js",  // This one
  "module": "dist/polished.es.js",  // And this one
  ...
}

Webpack prefers module over main. module points to dist/polished.es.js, and this file does have an ES export:

// polished/dist/polished.es.js
...
export { adjustHue$1 as adjustHue, ... };

This point is OK.

2. Check if there’s unused code that’s unnecessarily kept#

polished/dist/polished.es.js is written with ES exports. This means that tree-shaking should work properly, and the unused imports shouldn’t be included into the bundle. Then why different imports produce different file sizes?

Side effect is when a function changes something outside of itself – e.g. writes a value to a global variable or initiates a network request

The most possible reason is that polished/dist/polished.es.js contains some code that’s absent in our polished/lib/... files and that can’t be simply dropped by the tree-shaker. This is the code that could cause side-effects. E.g. if a file includes a top-level function call, the tree-shaker can’t remove the function even if its result isn’t used. The function could be causing side effects, and removing it could break the app.

Let’s compare the bundles that we have after importing polished in two different ways and verify this case.

To do this, I create a package:

# Shell
mkdir polished-test && cd polished-test
npm init -y
npm install polished webpack@2

Add two files that import Polished in two different ways:

console.log() helps finding the index.js file in the bundle + prevents webpack from removing the imports as unused
// index-import-package.js
import { opacify, transparentize } from 'polished';

console.log('polished', opacify, transparentize);

// index-import-files.js
import opacify from 'polished/lib/color/opacify.js';
import transparentize from 'polished/lib/color/transparentize.js';

console.log('polished', opacify, transparentize);

Add a special webpack configuration that emits two bundles:

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  entry: {
    // We’ll compare two different bundles,
    // thus two different entry points
    'bundle-import-package': './index-import-package.js',
    'bundle-import-files': './index-import-files.js',
  },
  output: {
    filename: '[name].js',
    path: __dirname,
  },
  plugins: [
    // We need to run UglifyJS to remove the dead code
    // (this will do tree-shaking), but prevent it
    // from uglifying the code (so it’s easier to read the bundle)
    new webpack.optimize.UglifyJsPlugin({
      // Disable several optimizations so that the bundle
      // is easier to read
      compress: { sequences: false, properties: false, conditionals: false, comparisons: false, evaluate: false, booleans: false, loops: false, hoist_funs: false, hoist_vars: false, if_return: false, join_vars: false, cascade: false },

      // Beautify the bundle after uglifying it
      beautify: true,

      // Don’t rename the variables
      mangle: false,
    }),
  ]
}

And run the build:

./node_modules/.bin/webpack

Now, I have two bundles, each with a different approach to importing stuff. I open them in my editor and switch to the structure view to their content. And here’s what I see:

A comparison between the content of two files. The left file is bundle-import-package.js, it has a lot of functions. The right file is bundle-import-files.js, it has much less functions.

bundle-import-package.js has more methods than bundle-import-files.js. Most likely, they are kept because of calls with side effects. Let’s dig deeper.

3. Find the exact cause of the problem#

So, bundle-import-package.js has a lot of functions that aren’t used but are still included. If we look through the file to see their usages, we’ll see a large snippet of code like this:

// bundle-import-package.js
// ...
function opacify(amount, color) {
    // ...
}
var opacify$1 = curry(opacify);
function desaturate(amount, color) {
    // ...
}
curry(desaturate);
function lighten(amount, color) {
    // ...
}
curry(lighten);
// ...

Here, desaturate and lighten are those unused functions, and opacify is a function we import in the client code.

This code comes to bundle-import-package.js from polished/dist/polished.es.js. The corresponding code in that file looks like this:

// polished/dist/polished.es.js
// ...
function opacify(amount, color) {
    // ...
}

var opacify$1 = curry(opacify);

function desaturate(amount, color) {
    // ...
}

var desaturate$1 = curry(desaturate);

function lighten(amount, color) {
    // ...
}

var lighten$1 = curry(lighten);
// ...

And this code comes into polished/dist/polished.es.js from the library sources. This is how it looks:

// polished/src/color/opacify.js
function opacify(amount: number, color: string): string {
  // ...
}

export default curry(opacify);

// polished/src/color/desaturate.js
function desaturate(amount: number, color: string): string {
  // ...
}

export default curry(desaturate);

// polished/src/color/lighten.js
function lighten(amount: number, color: string): string {
  // ...
}

export default curry(lighten);

So what happens here? dist/polished.es.js is built with Rollup. When the library authors do a build, Rollup grabs all the modules and converts exports (export default curry(lighten)) into variable assignments (var lighten$1 = curry(lighten)).

When we do import { opacify, transparentize } from 'polished', webpack tries to compile dist/polished.es.js and drop the unused code. It removes the desaturate$1 and lighten$1 variables because they aren’t exported, but it can’t drop the curry(darken) calls because curry could produce side-effects. And because functions like desaturate and lighten are passed into curry(), they are also kept in the bundle.

Screenshot of the editor
This is how you analyze the bundle: open the file structure, find a function that’s absent in the other bundle, and search for its usages

Solution#

To decrease the bundle size, we should do one of the following things:

Pure function is a function that doesn’t produce side effects
  • tell UglifyJS that it’s safe to remove curry() calls because it’s pure
  • or move currying into the functions instead of wrapping them.
Another option is passing compressor: { pure_funcs: ['curry'] } to the UglifyJS options, but Polished can’t control this

To tell UglifyJS that curry() calls are safe to remove, we have to mark each call with the /*#__PURE__*/ annotation. This way, the minifier will understand that this call is pure and will be able to optimize it:

We can’t just add the /*#__PURE__*/ annotation after export default. Rollup seems to remove comments if they are placed in that position
// polished/src/color/lighten.js
function lighten(amount: number, color: string): string {
  // ...
}
 
- export default curry(lighten);
+ const curriedLighten = /*#__PURE__*/curry(lighten);
+ export default curriedLighten;

The second approach is to move currying into the functions body. With it, we should do something like this:

// polished/src/color/lighten.js
- function lighten(amount: number, color: string): string {
-   // method body
- }
+ function lighten(...args) {
+   return applyCurried(function (amount: number, color: string): string {
+     // method body
+   }, args);
+ }
 
- export default curry(lighten);
+ export default lighten;

I prefer the first approach because it (almost) doesn’t complicate the code.

After adding the /*#__PURE__*/ annotations, minified bundle-import-package.js goes from 16 down to 11.8 kB. But that’s not the end – bundle-import-files.js is still smaller (9.86 kB). This is because there’re a few other places that should be optimized.

I’ll skip the part where I find them and jump right to the solution.

  • Change 1 and 2. Like with curry(), there’re two other places where the export is wrapped into a function. It’s polished/src/helpers/em.js and polished/src/helpers/rem.js. To optimize them, we should similarly add the /*#__PURE__*/ annotations.
  • Change 3. In polished/src/mixins/normalize.js, there’re two global objects that use computed object properties. When they are compiled, Babel transforms them to call the Babel’s defineProperty function. Because of this, UglifyJS can’t remove them. To solve the problem, we should either move these objects into the normalize() function that uses them or wrap them into getter functions.

And, when we apply these additional optimizations, we’ll have this:

                   Asset     Size  Chunks             Chunk Names
  bundle-import-files.js  9.87 kB       0  [emitted]  bundle-import-files
bundle-import-package.js  7.76 kB       1  [emitted]  bundle-import-package

bundle-import-package.js is now even smaller than bundle-import-files.js! Great.

I’ve submitted the pull request.

3 thoughts on “Case study: improving a popular library’s size for webpack users”

  1. Interesting read! The followup questions from me would be:
    1. is there any advantage in shipping module build as a single rolluped bundle? the only one i can imagine right now is that its creating a so called flat bundle – therefore keeping internals out of reach
    2. would be nice to have explanation why the package is smaller (significantly) from the files bundle after your tweaks

    1. as to my 1st question – it also allows rollup to inline babel helpers only once, instead of once per file

      1. As to the first question.
        This depends on the build approach we compare flat bundles with.

        If we compare a “flat bundle” (like a bundle produced by Rollup) with a “non-flat bundle” (like a bundle produced by webpack), then the flat bundle will have an advantage in size and speed of execution. Webpack wraps each module into a function, and this adds extra boilerplate code.

        If we compare a “flat bundle” with “non-bundled code” (like in the polished/lib directory), then, yes, the only advantage of the flat bundle I can think of is hiding the internals.

        Generally, from what I’ve seen, library maintainers prefer flat bundles because 1) for them, size and speed matters and 2) bundling code allows using the library in the browser.

        As to the second question.
        Looks like bundle-import-package.js is smaller than bundle-import-files.js because the former doesn’t include all the boilerplate code that’s present in the latter. The boilerplate code is additional function wrappers around modules (bundle-import-files.js consists of multiple modules), additional internal imports (__webpack_require__ to import stuff between modules), etc. You can see the diff yourself.

Leave a Reply

Your email address will not be published.