Reducing the Intercom Messenger bundle size by 65%
This is a cross post from the Intercom blog.
Intercom Messenger is an app our customers embed on their website. Because of that, performance and bundle size have always been a top priority for us.
The first step was to install webpack-bundle-analyzer to see what takes the most space in our bundle.
Since node_modules takes around 40% of our bundle, and they don’t change very often (we deploy to production multiple times a day, but rarely change the dependencies), we have decided to serve them from a separate bundle.
Webpack supports bundle splitting out of the box, and configuring it to split node_modules into separate bundle looks like this:
We have multiple entries in our webpack config, but for a start, we have decided to work only with frame entry.
chunks: chunk => chunk.name === 'frame'will make sure we split node_modules into vendor bundle only for the frame entry.
Optimizing the node_modules
With the node_modules as a separate bundle, bundle analyzer prints:
Looking deeper into vendor bundle I noticed we are bundling the underscore library.
That seemed pretty weird to me, as we don’t have underscore as our direct dependency. Investigating further revealed it’s getting pulled only because one of our dev dependencies needs it.
A quick search in codebase finds the offender:
Since we already use lodash, we can import throttle from lodash. Eslint will ensure we don’t accidentally import underscore any more:
Bundle analyzer shows us we are bundling a whole lodash all the time.
With babel-plugin-lodash we can cherry pick only modules we use from lodash, rather than bundling the whole library. It required us to do small refactor as chain sequences aren’t supported.
The size of the lodash after using the babel plugin:
Intercom-translations is our internal library that generates translations for all the supported locales.
The whole library is almost 60kb, but we can use just one translation at a time. This means 95% of that library is unused most of the time.
The logic for importing those translations looks like:
We can use dynamic imports to bundle only English locale to the main bundle, and dynamically import any other only when it’s needed.
To add support for dynamic imports, we need to add this two packages to our babel config:
@babel/plugin-syntax-dynamic-import will allow parsing of import(), and dynamic-import-node will transpile import() to a deferred require() in our tests.
This will add additional request to small 3kb file when using non-English locale but will reduce our initial bundle size by 55kb after gzip.
Sentry is a library that we are using to collect errors in our app.
The truth is, most of our customers will not need this client to be bundled in, as errors are pretty rare. Our simplified sentry integration looks like:
Our setup already disables global handlers and only expose logError function that captures the errors manually.
Using the dynamic imports we can again import sentry client only when needed.
If you are using redux-raven-middleware, you need to remove it, as it always includes sentry client. However, you can create your custom middleware to store actions and populate breadcrumbs on error.
PSL package is a public suffix list.
Our messenger always tries to set cookies on the most generic domain. This means if you are on https://a.b.c.my-domain.com we will set cookies on .my-domain.com. However getting the TLD is a hard task due to many nonstandard like co.id. So at some point, we have decided to bite the bullet and add a list of all possible TLDs.
Solution to this was to move this logic to a server and remove psl package altogether.
CSS is a library to parse and stringify css.
It exposes two methods: parse and stringify. Looking into the codebase, I found out that we are using only parse API:
After changing our import to parse source:
pre gzip size suddenly drops from 122kb to 10kb:
After the vendor, we started to look into the application. A proposed solution was to split our application by routes. Loadable component makes this very straightforward.
Our simplified App component looks like:
Code splitting Messenger component using Loadable looks like this:
There are various optimizations you can do. We have decided to pre-load messenger bundle once you hover over the launcher. This will remove most of the unwanted delays when waiting for additional bundles to load.
We now serve multiple smaller bundles instead of one large bundle:
Various teams can now safely add new features to messenger without increasing the initial size of the messenger.
We have reduced our messenger size down to 280kb to boot the app. That is 300kb reduction after gzip. 300kb can take up to 4 seconds on the fast 3G network and up to 14 seconds on slow 3G network. This is a massive improvement for mobile users.
Our biggest technical debt is the CSS that can’t be split into smaller bundles, but we already have a plan to change that.
For relatively small effort we got significant gains in performance and bundle size. Smaller bundle means, less code to download, less code to execute, and less code to maintain.
Try to look into your bundle as well, you most likely adding duplicated code, or code that is rarely executed and can be loaded asynchronously.