6 tips to optimize bundle size

mbernardeau

Mathias Bernardeau

Posted on May 13, 2020

6 tips to optimize bundle size

Disclaimer:

This article includes optimizations that are only valid for an app bundled with webpack, or only for a React app. Some optimizations also assume you have control over the webpack configuration of your app.

Measure and visualize bundle size

Valid for: any app bundled with Webpack

The tool webpack-bundle-analyzer can produce an easily understandable view of the composition of a JS bundle.

The easiest way to use it is to generate a stats file with webpack and to launch the tool with npx.

webpack --profile --json > stats.json
# Assuming generated bundled files are in the dist folder
npx webpack-bundle-analyzer stats.json dist/  
Enter fullscreen mode Exit fullscreen mode

Example of a webpack-bundle-analyzer tab

webpack-bundle-analyzer launches a tab in your browser which look like this

To understand what the different sizes mean:

  • Stat size is the size of the input, after webpack bundling but before optimizations like minification
  • Parsed size is the size of the file on disk (after minification). It is the effective size of the JavaScript code parsed by the client browser
  • gzip size is the size of the file after gzip (most likely the effective content size transmitted over the network)

1. Avoid libraries global imports

Valid for: any bundled app or lib
Cost: Low
Impact: High

With some large libraries, it is possible to import only the parts we use instead of the entire library. If done right, this can save a lot of unused bundle size.

Examples of splittable libs: lodash, date-fns, react-bootstrap...

The downside of this is that only one global import in the app or in dependencies that transitively depend on these libs is necessary to make the bundler include the whole dependency.

Using webpack-bundle-analyzer a global import of lodash will look like this
Using webpack-bundle-analyzer, a global import of lodash will look like this

In this capture you can see that necessary functions are bundled up to 3 times (once in lodash.js, once in lodash.min.js, and once in one-by-one imports). This is the worse case scenario.

There are two ways to enforce one-by-one imports. Note that both of these methods don't apply to dependencies, only to your own code.

Via a babel plugin

The plugin babel-plugin-transform-imports has the ability to replace global destructured imports by one-by-one imports.

Configured like this:

# .babelrc
"plugins": [
  ["transform-imports", {
    "lodash": {
      "transform": "lodash/${member}",
      "preventFullImport": true
    }
  }]
]
Enter fullscreen mode Exit fullscreen mode

It will have the following effect:

import { map, some } from 'lodash'
// will be replaced by
import map from 'lodash/map'
import some from 'lodash/some'
Enter fullscreen mode Exit fullscreen mode

Note that the option preventFullImport will tell the plugin to throw an error if it encounters an import which would include the entire library.

Via an ESLint rule

The downside of the first method is that two methods of imports of the same function are valid, meaning it becomes harder to enforce a single import style in a single project.

Fortunately it is possible to configure the no-restricted-imports rule to throw an error if a global import is encountered.

// .eslintrc
"no-restricted-imports": [
  "error",
  {
    "paths": [
      "lodash"
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

It will produce an error for the following import:

import { map } from 'lodash'
Enter fullscreen mode Exit fullscreen mode

But not for a specific module import like this.

import map from 'lodash/map'
Enter fullscreen mode Exit fullscreen mode

You can of course combine these two methods to enforce a specific style.

2. Use code-splitting

Valid for: Web apps bundled with webpack
Cost: Low
Impact: Variable

Using dynamic imports and Suspense, it is possible to split application code in async chunks that can be loaded on-demand. This allows to reduce the size of the bundle downloaded initially. It does not reduce overall bundle size (it even slightly increases it).

Configuration:

# webpack.config.js
optimization: {
  splitChunks: {
    // include all types of chunks
    chunks: 'all',
  }
}
Enter fullscreen mode Exit fullscreen mode

By default a “vendors” chunk is created, separating application code from dependencies. This can have a positive impact when updating the application. If only the application code changes (assuming resources are correctly cached), the client can save the cost of downloading vendors files. This behaviour can be disabled by setting:

optimization: {
  splitChunks: {
    // include all types of chunks
    chunks: 'all',
    cacheGroups: {
      vendors: false,
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Be careful not to be overzealous with code splitting as this can slow down some user actions as we have to download, parse and execute more code. Depending on the structure of the application, it possible that adding a chunk implies downloading several files (with HTTP 1, there is a limit on parallel connections to the same domain).

The recommended way is to create a chunk per route. This is not an absolute rule.

How to export a lazy loaded component:

// myComponent.lazy.jsx
import React, { Suspense } from 'react'
import LoadingIndicator from '..'

// Create a lazy component using React.lazy
export const MyComponentLazy = React.lazy(() =>
 import(/* webpackChunkName: "my-component" */ './myComponent'),
)

const MyComponent = props => (
 <Suspense fallback={<Loading Indicator />}>
   <MyComponentLazy {...props} />
 </Suspense>
)

export default MyComponent
Enter fullscreen mode Exit fullscreen mode

Here we use the dynamic import syntax to tell Webpack to bundle a separate chunk for MyComponent (and all its dependencies).

Setting the webpackChunkName is optional, this allows to control the name of the generated file (with the corresponding webpack configuration). If two lazily imported components have the same name, they will be concatenated in a single chunk.

React.lazy is used to allow the lazily imported component to be rendered like a regular component. Suspense allows to provide a fallback (component that will be rendered while the import is not resolved).
Note that Suspense can be further up the component tree, depending on what the users should see during load.

See React documentation for a more complete explanation of lazy and Suspense.

3. Do not include source maps

Valid for: Web apps and libs bundled with Webpack
Cost: Low
Impact: Variable

Source maps are a link between source code and generated bundled files. While it can be really useful to use browser debuggers, it shouldn't be included in the production bundle.

For JS source-map, the option devtool controls how source-maps are generated.
For development, 'eval-source-map' is a good choice (we see the original source and rebuilds are fast).
For production, setting false will completely disable source-map generation. As it can be useful to debug generated bundled app, the best way to keep them in production is to set devtool: 'source-map'. It will generate a separate file (downloaded only if browser devtools are open) linked by a comment added in the original bundle that looks like this: //# sourceMappingURL=app.daa9676c2167d965d0ae.js.map.

For CSS, Less or Sass source-maps, the configuration depends on the loader used. Using css-loader, sass-loader and less-loader, I would recommend setting options: { sourceMap: true } in development inside loader configuration, and options: { sourceMap: false } in production (as this is the default, you can safely omit the property in production).

4. Remove replaceable libs

Valid for: any bundled app or lib
Cost: Variable
Impact: Variable

It can be very tempting to add a library which does meet the user requirement but also do much more. Possible reasons include not knowing future needs of users or simply to deliver faster.

Adding unneeded complexity can have a huge impact on the bundle size.

In my project, we found out that we used libphonenumber-js for only two use cases:

  • Format a french phone number
  • Validate an input field which only allows french phone numbers

Having to deal only with french phone number greatly reduce the complexity needed for these kind of features. libphonenumber-js is a great library -- just not fitted to our needs.

Rewriting these feature using only vanilla JS took only a few hours and saved us ~150 KiB of JS bundle size.

For each dependency, you should wonder:

  • Do we use only a small part of the dependency ?
  • Do we have the capacity to rewrite it in a reasonable time ?

If the answer to both questions is yes, it seems that rewriting code which meet the needs of the project (and only them) is a good idea.

5. Remove prop-types

Valid for: React apps
Cost: low
Impact: High

With React, defining prop-types enables validation of props passed to a component. Although it is really useful in development, prop-types are disabled in production (mostly for performance reasons).

But their definition is still included in the produced bundle.

The Babel plugin transform-react-remove-prop-types completely deletes prop-types definitions from the generated bundle. However, prop-types included by dependencies are not removed.

// .babelrc
{
  "env": {
    "production": {
      "plugins": [
        [
          "transform-react-remove-prop-types",
          {
            "removeImport": true
          }
        ]
      ]
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Warning: only activate this plugin in the production environment.

6. Target recent browsers

Valid for: any web app
Cost: low
Impact: medium

To include polyfills, you probably already use core-js and regenerator-runtime.

By default, all polyfills are included and core-js weights approximately 154KiB while regenerator-runtime is only 6.3KiB.

By targeting only recent browsers, it is possible to reduce the size of included polyfills.

Babel-preset-env has the ability to replace global imports of core-js by specific imports dependings on the targeted browsers.

To configure the preset:

// .babelrc
"presets": [
  [
    "@babel/preset-env",
    {
      "useBuiltIns": "entry",
      "corejs": "3.6"
    }
  ],
],
Enter fullscreen mode Exit fullscreen mode

The version of core-js has to be provided.

With "useBuiltIns": "entry", you only have to import these two dependencies once:

import 'regenerator-runtime/runtime'
import 'core-js/stable'
Enter fullscreen mode Exit fullscreen mode

These two imports will be replaced by specific imports depending on the targeted browsers.

To declare targeted browsers, the preset uses the browserslist syntax.

"browserslist": "last 2 Chrome versions, last 2 Firefox versions, last 2 safari versions",
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hope this helps !

Let me know if this is useful to you and how much bandwidth and client memory you saved

💖 💪 🙅 🚩
mbernardeau
Mathias Bernardeau

Posted on May 13, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

6 tips to optimize bundle size
webpack 6 tips to optimize bundle size

May 13, 2020