Optimizing JavaScript packages for tree shaking

Front-end

Optimizing JavaScript packages for tree shaking

Geoffrey Dhuyvetters

Geoffrey Dhuyvetters

As an author of (open source) packages, I think you have the responsibility to protect the bundle size of your package consumer. When you publish a package that exports a whole range of modules (for example lodash, ramda, date-fns…) you want to make sure the package is exported in such a way that the consumer of your package (mostly bundlers) can optimize size.

One of the techniques a bundler applies is tree shaking. Tree shaking is a dead code removal technique that makes sure all unused modules are excluded from the output. It’s not hard to prepare your package for tree shaking, the decisive factor is the module format you choose.


Let’s have a brief intro on the 2 most popular module formats in JavaScript.

CommonJS was originally created in 2009 by Mozilla. Initially, it was called ServerJS but was renamed to CommonJS in the same year. This format was popularized by Node.js as it is their de facto module system. You export a module using module.exports & import via require( ).

add.js (module)

const add = () => a + b;
module.exports = add;

index.js (consumer)

const add = require('./add.js');
console.log(add(1, 2));

CommonJS is dynamic which means you can use require( ) within loops/functions etc, this enables lazy loading patterns that can be useful in performance sensitive scenarios.

In this function add.js or subtract.js will only be loaded when the require( ) is reached.

const addOrSubtract = (a, b, action = 'ADD') => {
  if (action === 'ADD') {
    return require('./add.js')(a, b);
  }

  if (action === 'SUBTRACT') {
    return require('./subtract.js')(a, b);
  }

  throw new Error('Please provide a valid action (ADD or SUBTRACT).');
}

In 2015 a module format was added to the ECMAScript specification, which makes it the ‘official’ JavaScript module format. It’s mostly referred to as ES2015 or ES modules. This specification uses a combination of import & export.

add.js (module)

const add = () => a + b;
export default add;

index.js (consumer)

import add from './add.js';
console.log(add(1, 2));

This is just a basic example of importing and exporting, there is a lot more in the spec (and open proposals). ES Modules are (mostly) static, you define 1 or multiple imports at the top of a file and use them in the code below. If you need a dynamic CommonJS style import (e.g. require) you can use import( ).

While some browsers support <script type="module"/> as a way to import JavaScript modules, your (web) project will likely need a bundler such as Webpack, Rollup or Parcel.

The main task of the bundler is to make sure all dependencies are mapped, linked and bundled into 1 or more files (chunks). These chunks can communicate via the extra runtime layer a bundler provides. Both module formats are supported by all major bundlers (sometimes some extra configuration is needed), so either (or a mix of both) is a valid option when you’re creating a web project.

In Node, you can use CommonJS modules out of the box or use ES modules that you transpile back to CommonJS (via Babel) or directly via tools like esm. Official untranspiled ES modules will find their way into Node soon via the .mjs extension (some people call it the Michael Jackson solution, it’s a long story…).

If you want to create a cross-platform package (support for both web & Node), your safest bet is to expose CommonJS modules. Most authors stop here, but pushing it a step further by adding an ES module entry point makes your code statically analyzable.

When your code is statically analyzable, it’s perfect for tree shaking ?

operations.js (package)

export const add = () => a + b;
export const subtract = () => a - b;

index.js (consumer)

import { subtract } from './operations.js';
console.log(subtract(1, 2));

In the code above, Webpack will notice we’re not using the add module and will optimize your bundle by removing it from the generated bundle.

Let’s try out this concept in a more realistic setting. In this piece of code, I’m using flow & add from lodash.

First, let’s import both modules via the lodash package (which exposes CJS modules).

import { flow, add } from 'lodash'; // <--- we're importing 'named exports' here

const square = n => n * n;

const app = () => {
  var addSquare = flow([add, square]);
  console.log(addSquare(1, 2));
};

After running a build with Webpack (in production mode), you can see the output is 70.3 Kb.

Hash: 969a53c51fd1bdfd1c59
Version: webpack 4.29.5
Time: 282ms
Built at: 02/25/2019 8:05:06 AM
  Asset      Size  Chunks             Chunk Names
main.js  70.3 KiB       0  [emitted]  main
Entrypoint main = main.js
[1] ./src/index.js 180 bytes {0} [built]
[2] (webpack)/buildin/global.js 472 bytes {0} [built]
[3] (webpack)/buildin/module.js 497 bytes {0} [built]
    + 1 hidden module

As you can see, Webpack is including the entirety of Lodash. Due to the dynamic nature of CommonJS, it’s hard to determine what to remove.

By switching to lodash-es (the ES module version), Webpack can tree-shake (or shake a tree?) and removed 63 Kb of unused code.

import { flow, add } from 'lodash-es'; // <--- only change

const square = n => n * n;

const app = () => {
  var addSquare = flow([add, square]);
  console.log(addSquare(1, 2));
};
Hash: 3594f44a845c0950451b
Version: webpack 4.29.5
Time: 1160ms
Built at: 02/25/2019 8:07:43 AM
  Asset      Size  Chunks             Chunk Names
main.js  7.01 KiB       0  [emitted]  main
Entrypoint main = main.js
  [1] (webpack)/buildin/global.js 472 bytes {0} [built]
  [2] ./src/index.js + 52 modules 38.2 KiB {0} [built]
      | ./src/index.js 183 bytes [built]
      |     + 52 hidden modules
[588] (webpack)/buildin/harmony-module.js 573 bytes [built]
    + 586 hidden modules

Building time is impacted a bit, but the effect on the bundle size is enormous (just imagine the impact on a package that contains 300 icons, or big SVG visuals).

So, how do we create a package that exposes both CommonJS & ES modules while making sure we don’t break cross-platform support? Publishing 2 separate packages is an option (e.g. lodash/lodash-es). But there is a nicer, more maintainable option that obviates the need to publish twice. We can provide an extra build step that creates an ES version of our package and links it via package.json.

First, you start by specifying a Babel configuration for both systems using environment settings.

{
  "env": {
    "es": {
      "presets": [
        [
          "@babel/preset-env",
          {
            "modules": false
          }
        ]
      ]
    },
    "cjs": {
      "presets": [
        [
          "@babel/preset-env"
        ]
      ]
    }
  }
}

The modules: false part makes sure @babel/preset-env is preserving ES modules and doesn’t transpile them back to CommonJS (the default behaviour).

2. in the next step we provide the correct build scripts in your package.json

{
  ...
  "scripts": {
    ...
    "build:cjs": "NODE_ENV=cjs babel src --out-dir cjs",
    "build:es": "NODE_ENV=es babel src --out-dir es",
    "prepare": "build:cjs && build:es",
    ...
  }
  ...
}

3. finally, you need to link both entry points to their specific fields. We use main for CJS and module for ES. (in package.json)

{
  ...
  "main": "cjs/index.js",
  "module": "es/index.js",
  ...
}

Module is an unofficial property in your package.json, most bundlers that support tree shaking use it as the entry point.


That’s all there is to it. By adding these steps you’re actively contributing to a smaller web. Making the world a little bit better, a few Kbs at a time. ?

Hire Geoffrey as a speaker?

Contact us
Geoffrey Dhuyvetters

Geoffrey Dhuyvetters

This former teacher likes all things front end (the more complex web applications get, the happier Geoffrey becomes). His weak spot? JavaScript and all its quirks. Nor is he afraid to experiment a little with Node.js. Geoffrey used to be in a post-punk/noise rock band and a couple of one-off noise bands. In the process he started building a +1,000 record collection.