Reducing the Intercom Messenger bundle size by 65%
Main illustration: Ryan Johnson
Businesses put the Intercom Messenger on their websites because they want real-time communication with their users. Real-time implies a Messenger that’s zippy and responsive.
To help the Messenger load as quickly as possible on the web, we recently embarked on an effort to optimize its bundle size.
A fast load time is important because it makes the Messenger feel like a natural part of the websites they’re on, rather than an add-on. It also helps with the overall speed of the site which is important for the user experience and Google search performance. Most importantly, a fast-loading Messenger helps businesses connect with their customers the instant they land on their website.
So here’s how we set out to reduce the size of our Intercom Messenger using webpack bundler features. The steps we took can be applied to any JavaScript app.
More features = larger bundle size
The Intercom Messenger is a modern react.js app with many components, third-party libraries, and stylesheets. Historically, the Messenger was served as a single JavaScript file containing all the application code as well as vendor code. This file, called frame.js
, is the entry point to our app and we refer to it as the Messenger bundle.
With the introduction of many new enhancements over the years, the size of the bundle had slowly grown. At the end of last year, we were serving almost 600 kb of gzipped JavaScript, up from 400 kb earlier in the year.
Since we ship features to our codebase every day, we knew this problem would only increase over time.
What’s in the Messenger?
The first step toward reducing the size of our bundle was to see what took up the most space in our codebase. Here’s a visualization of all the code that was in frame.js
, generated by webpack-bundle-analyzer:
As you can see, three main parts make up the Messenger bundle.
Node_modules
, which are vendor libraries we use like React, Redux, and Lodash.App
, which contains our application code, mostly react.js componentsStylesheets
, which comprise sass compiled styles
Optimizing the Messenger bundle
Once we understood the different parts of the Messenger bundle and their respective sizes, we identified the changes that would make the largest impact. We looked for code that was being duplicated or rarely executed, as well as code that could be loaded asynchronously. Here are the major changes we made.
1. Split out vendor packages
At Intercom we deploy dozens of new versions of our Messenger every day. Every time we deploy, we generate a new Messenger bundle that website visitors have to re-download.
Since vendor packages make up around 40% of our bundle, and they don’t change very often, the first step we took was to split them out into a separate bundle.
Webpack supports bundle splitting out of the box. Configuring it to split vendor packages into a separate bundle looks like this:
If you have multiple entries in your webpack config, you can target a single entry with this code:
chunks: chunk => chunk.name === [your entry]
With this change, we now load our application bundle (frame.js
) and vendor bundle (vendor.js
) separately when people visit websites using the Messenger. The vendor bundle gets cached by the browser and returning visitors no longer re-download the bundle if it hasn’t changed.
2. Split our application by routes
After reducing the size of the vendor bundle, we took a closer look at our application bundle and decided to try splitting the application by routes.
To do this, we introduced a slightly modified Loadable component. This component lets us show a loading state for the Messenger while we load the vendor bundle. If the network request fails, the component will take care of retries and show an error state if needed.
This is what our simplified App component looks like:
Code splitting the Messenger component using Loadable looks like this:
Since splitting the Messenger into separate bundles has a latency cost, we’re now pre-loading the Messenger bundle the moment a user hovers over the Messenger launcher, instead of waiting for them to click the launcher. This removes most of the unwanted delays when the site performs additional network requests.
3. Refactoring the stylesheet
With the vendor packages and our application code optimized, it was time to look at our Sass stylesheet, which made up half of the frame.js bundle.
Styles in our Messenger were historically done via classic CSS files, with a Sass preprocessor. We couldn’t tell which styles belong to which component, so it wasn’t easy to bundle split them. This meant we had to mount the whole stylesheet, even if we were rendering just the launcher.
Our Sass stylesheet had over four thousand rules that browsers had to download and parse all the time, even when most of the stylesheet rules were not being used.
So we decided to introduce a CSS-in-JS approach to write the CSS. There are many benefits of writing your styles inside JavaScript files, but the most important benefit is the ability to statically link styles and components. By determining which styles belong to which component at compile time, we could now bundle split them together using the Emotion library. With Emotion, you can declare your styles inside template literals and mount those styles only when the component that uses them is rendered. Since the styles are React components, we get bundle splitting support for free.
“People who never interact with Messenger won’t be required to download extraneous code that might slow down their site experience”
With this change, website visitors no longer download the Sass stylesheet before they open the Messenger. People who never interact with Messenger won’t be required to download extraneous code that might slow down their site experience.
For now, we’ve migrated just our launcher to Emotion since refactoring whole codebase will take some time. Once we convert the whole codebase to Emotion, the Sass stylesheet can be removed. In the meantime, we’ve split the Sass stylesheet into a separate bundle and load it only if a render component hasn’t been converted to Emotion yet.
4. Further optimizing the vendor bundle
Our vendored dependencies were now in a separate bundle, but within the vendor bundle, there were still opportunities to reduce the size of various packages.
We found several instances where we were importing whole libraries rather than the subset of functions that were actually being used. By implementing dynamic imports and other code changes, we’ve been able to reduce the size of various vendor packages. For example:
- Intercom-translations package: This package is our internal library that generates translations for all the supported locales. The whole library is almost 60 kb, but 95% of that library is unused most of the time as we can show only one locale at a time. By using dynamic imports to include only the English locale by default, we were able to reduce the size of the package by 55 kb gzipped.The logic for importing those translations looks like:
- PSL: This package is a list of all public domain name suffixes and we were using it to determine the best domain for setting cookies. But since top level domains have many edge cases due to non-standard domains like co.id, we recently decided to add a list of all possible TLDs to the server and check the logic there. As a result, we were able to remove the PSL package and save 36 kb of gzipped data.
- Sentry: This package is used to collect errors in our app, but since errors are pretty rare for us, we decided to stop bundling this client by default and save 25 kb of gzipped data.
If you are usingredux-raven-middleware
, you’ll need to remove it, as it will always includes the Sentry client. However, you can create your custom middleware to store actions and populate breadcrumbs when errors pop up. - Lodash: We were bundling the entire Lodash module, but in reality only using a subset of Lodash functions in our Messenger. So we reduced the size of the Lodash package we were using from 25 kb to 11.7 kb using babel-plugin-lodash to pick out the specific modules we use. It required us to do small refactor as chain sequences aren’t supported.
- CSS package: We were loading this entire package to parse and stringify CSS. However, we were really only using the
parse
API. Tree shaking doesn’t work in the CSS module because it doesn’t have the sideEffects property inpackage.json
. After changing our import fromCSS
toCSS/lib/parse
, we reduced the gzipped size of the file from 10 kb to 1.7 kb. - Underscore: We were using Underscore to import
throttle
, which was unnecessary since we already use Lodash and can importthrottle
from Lodash. A new Eslint rule will prevent Underscore from being accidentally imported moving forward.
Future-proofing the Messenger bundle size
As a result of these changes combined together, we’ve been able to deliver a faster Messenger experience to our customers. It now takes only 240 kb to load the Messenger on the web, down from nearly 600 kb. On mobile, the savings of 400 kb shave off up to five seconds of loading time on a fast 3G network and up to 11 seconds on a slow 3G network. That is a massive improvement for mobile users!
Visitors to websites using the Messenger no longer need to download the entire Messenger bundle multiple times a day. By splitting the large Messenger bundle into smaller bundles, we reduce the files that visitors need to load to just the ones they truly need.
According to Google Lighthouse, our site now has a score of 100, up from the previous score of 59.
Messenger score in Google Lighthouse by the end of 2018
Messenger score in Google lighthouse now
Our engineering teams also benefit from this change since they can now safely add new features to different parts of the codebase without impacting the initial size of the Messenger bundle.
If it’s been awhile since you’ve examined the load times for your JavaScript app, do a review and see if duplicate code and rarely used code have piled up over time, or if you’ve got new opportunities to load code asynchronously. In the age of real-time communications, even a second saved can make a huge difference for your user experience.
Love this kind of work? Check out our engineering openings in San Francisco, Dublin, and London.