Paul Cowan Contract software developer

Does my bundle look big in this?

Slimming down your bundle with Rollup

3 min read 1106

Does my bundle look big in this?

Where do we want to get to?

The code below is bundled with Rollup. Rollup has determined the bare minimum of imports the code needs to execute with a process known as tree shaking.

import React from 'react';
import { useFormikContext, useField } from 'formik';
import isEmpty from 'lodash/isEmpty';
import { DatePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, TransitionComponent, TransitionType } from '@my/component-library';

Tree shaking is a technique used to eliminate dead code. Rollup has determined that I only need two components from formik.

Does my bundle look big in this?

It is no secret that most modern-day JavaScript-heavy applications ship far too much code to the browser.

bundle visualization

In the image above, the vast size of react-dom.production.min.js is there for all to see.

formik has needlessly doubled its size by including nearly all of lodash. Lodash is a colossal problem for so many JavaScript bundles.

How?

It seems unlikely that formik is using all the imported modules that are fattening up the bundle size. What is needed is a way of eliminating dead code or only importing the modules that are used by the importee.

In order for a bundler to identify dead code, it will need to perform static analysis on the codebase.

CommonJS increases bundle size

I use TypeScript for everything I can these days, and up until recently, I had the module setting of tsconfig.json set to commonjs.

CommonJS modules are harder to optimize because dynamic imports and exports are supported:

We made a custom demo for .
No really. Click here to check it out.

const a = require(localStorage.getItem('somekey'));

const b = 'exportThis';

module.exports[b] = (a) => a;

Because require is actually a function call, we can use a dynamically computed string to determine at runtime the module to be loaded.

A static analyzer will not even attempt to decipher dynamic imports and exports and instead grab everything.

ESM (Ecmascript) module syntax

Both import and export statements are part of the language with ESM modules. There is no ambiguity, and the lack of dynamism facilitates static analysis.

Dynamic imports are still possible in ESM modules for code splitting.

Rollup is the market leader for eliminating dead code

I have used Webpack for a long time and Webpack works by wrapping each module in a function that implements a loader and a module cache. At runtime, each of these modules is evaluated in turn to populate the module cache. The Webpack approach makes things like hot module replacement (HMR) possible but incurs overhead with this approach.

Rollup takes a different approach — it puts all code at the same level, which is known as scope hoisting. The resulting bundle is smaller with much less overhead as there is no per-module evaluation.

The trade-off is that rollup relies on ESM module semantics. Step 1 of any journey to a smaller bundle size is to turn any CommonJS packages into 100% ESM packages.

Package.json module resolution

Bundlers such as rollup or Webpack generally have a mechanism to specify which field in the package.json file is the entry point.

If the consuming package has an import such as:

import * as D3 from 'd3';

The following fields of package.json will determine what the entry point for the module is:

  • type – files ending with .js will be loaded as ES modules when the nearest parent package.json file contains a top-level field type with a value of module
  • module – if this field states that the imported file will be an ESM module
  • main – this field is generally used to resolve CommonJS modules
  • browser – never used by Node. We can produce different bundles for web and Node by using this field

version, module, browser, main fields in packagejson

With Webpack, it is possible to set a precedence of which fields will be searched first using the mainFields option:

resolve: {
  mainFields: ['module', 'browser', 'main'],

With Rollup you can use the @rollup/plugin-node-resolve rollup plugin:

resolve({
  mainFields: ['module', 'browser', 'main'],

The first step in our journey should be to set the type to module and supply a module field:

{
  "name": "@ds/util",
  "version": "6.3.0",
  "type": "module",
  "module": "dist/index",
  "browser": "dist/index",
  "main": "dist/index",
}

I had problems with Webpack if there was a file extension either module , main or browser and Webpack relied on the existence of a browser field when targeting web builds.

Gotcha with mjs files

ES modules are the target for files with an .mjs extension. Later versions of Node assume that an import of an mjs file will be ESM compliant.

In the browser world or more accurately, the bundler world, it is more of a convention, but Webpack and Rollup will treat this file differently and compile to a different target.

I ran into problems with dependencies like React and react-router that are CommonJS dependencies. The solution was to output a file with a .esm.js file extension.

Boot require into touch

The main refactoring that I encountered was to remove require statements that were importing scss files:

const styles = require('./Start.module.scss');

Which becomes:

import styles from './Start.module.scss';

TypeScript

With TypeScript, I had to instruct the compiler to transpile to ESNext.

In tsconfig.json, the module field changed from :

"module": "CommonJS",

to

"module": "ESNext",

Rollup

This is the Rollup configuration I settled upon:

const bundle = await rollup({
    input: inputFile,
    external: (id: string) => {
      return !id.startsWith('.') && !path.isAbsolute(id);
    },
    treeshake: {
      moduleSideEffects: false,
    },
    plugins: [
      resolve({
        mainFields: ['module', 'browser', 'main'],
        extensions: ['.mjs', '.esm.js', 'cjs', '.js', '.ts', '.tsx', '.json', '.jsx'],
      }),
      json(),
      postcss({
        extract: false,
        modules: true,
        use: ['sass'],
      }),
      typescript({
        clean: true,
        typescript: require('typescript'),
        tsconfig: paths.tsConfig,
        abortOnError: true,
        tsconfigDefaults: {
          compilerOptions: {
            sourceMap: true,
            declaration: true,
            target: 'esnext',
            jsx: 'react',
          },
          useTsconfigDeclarationDir: true,
        },
        tsconfigOverride: {
          compilerOptions: {
            sourceMap: true,
            target: 'esnext',
          },
        },
      }),
      babel({
        exclude: /\/node_modules\/core-js\//,
        babelHelpers: 'runtime',
        ...babelConfig,
      } as RollupBabelInputPluginOptions),
      injectProcessEnv({
        NODE_ENV: 'production',
      }),
      sourceMaps(),
      minify === true &&
        terser({
          compress: {
            keep_infinity: true,
            pure_getters: true,
            passes: 10,
          },
          ecma: 2016,
          toplevel: false,
          format: {
            comments: 'all',
          },
        }),
    ],
  });
}

The following plugins where used:

Calling rollup’s rollup function with the above configuration returns a bundle object that can write the bundle to disk with the following code:

await bundle.write({
  file: path.join(paths.appBuild, 'index.js'),
  format: 'esm',
  name: packageName,
  exports: 'auto',
  sourcemap: true,
  esModule: true,
  interop: 'esModule',
});

The package is now ESM compliant.

Wrapping up

The world is turning to ESM modules slowly but surely. There is the promise of a smaller bundle size to lure you to the promised land.

I use Rollup to bundle my packages but still use Webpack for hot module replacement in development.

Whatever the bundler, it is making more and more sense to move away from CommonJS.

Are you adding new JS libraries to improve performance or build new features? What if they’re doing the opposite?

There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.

LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.

https://logrocket.com/signup/

LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Build confidently — .

Paul Cowan Contract software developer

Leave a Reply