Updated: Apr 12, 2020
As client side applications become more complex, their bundle sizes become bigger and bigger. Devices and regions with slower connections suffer the most from increasing bundle sizes and it’s just getting worse every day. In this post I will go over a real world example from my work at Wix where I was able to trim my bundle size by about 80% using Webpack Analyzer and React Lazy/Suspense.
How Early Should I Optimize?
If you are just getting started with your new and shiny web application, you are probably trying to focus on getting off the ground and making your product come to life. You are probably not focusing on performance or bundle sizes too much. I can relate to this. However, in my experience, this is something you should think about right from the start. Good architecture and trying to “think about the future of your app” will save you a lot of time and tech debt in the long run. Obviously, it’s hard to “guess” everything ahead of time, but you should try to do your best.
There are two great tools which I think you should use right from the start. These tools will help you recognize “problematic” NPM packages even before you rely on them in your app:
1. Bundlephobia — A website which shows you how much an NPM package will increase your bundle size. This is a great tool which might help you make better choices with regards to picking a third party package you might need or how to design your architecture so your app doesn’t become bloated. In the screenshot below, I checked the popular time parsing library “moment”. You can see that it’s big. Almost 66KB gzipped. For a lot of people with blazing internet speeds, it’s nothing. However, look how long the download time increases for 2G/3G networks, 2.2s and 1.32s respectively, and that’s just for ONE PACKAGE.
2. Import Cost Extensions — This is a very cool extension for various popular editors (+1 million downloads for VS CODE) which shows you how much importing a package will “cost”. What I really like about this extension is that it helps identify specific problematic areas “on the fly”. This gif (which is taken from Import Cost’s Github page) shows a perfect example of how importing the uniqueId property from Lodash brings in the entire Lodash package (70KB) as opposed to importing just the uniqueIdfunction directly which adds just 2KB. Read more about Import Cost here
Bloated Bundles - A Case Study
So you’ve built your amazing app, it works great on your high speed internet connection and your super powered, ultra fast with extra RAM dev computer. Then after a little while, you start getting complaints from your users or from your analytics team that your app’s load time is not so great. This recently happened to me after we released a new feature I was working on here at Wix.
To give you some perspective, let’s first look at the new feature. The feature is a new progress bar at the top of your sidebar. The goal is to expose various steps you should take in order to have a better chance of succeeding with your business (connect SEO, add shipping regions, add your first product, etc).
The progress bar updates automatically by connecting to the server via websockets. When the user completes all the recommended steps, a tooltip with a “happy moment” is shown in order to celebrate your achievement. After the “happy moment” is closed, the progress bar is hidden and will never be shown again for this site.
So what was happening? Why was I getting complaints from our analytics team saying that the load time for the page has increased? Looking at the network tab in Chrome’s dev tools it quickly became apparent that my bundle was big — 190KB big.
I thought to myself, why should this small feature have such a (relatively) big bundle?! Why indeed…
Finding The Problematic Areas In Your Bundle
After realizing the bundle size was too big, it was time to find out why. A great tool to help find problematic areas in your bundle is Webpack Bundle Analyzer. This tool will open a new tab in your browser and it will visualize all of your dependencies.
I ran the analyzer on my bundle and these were the results:
React Lazy/Suspense to The Rescue
After I dusted myself off, it was time to fix the problem. It was obvious that there is no need to bring in everything which was needed for the animation right from the start. In fact, there was even a very good chance that the “happy moment” won’t be shown at all during the current user’s session. I read up on React Lazy/Suspense which came out recently and I thought that this might be a great chance to test it out.
If you are not familiar with the concept of lazy components, the idea is that you split your app into smaller pieces and then fetch the relevant pieces only when you need them. So in my case, I wanted to break apart the component which was responsible for rendering the “happy moment” and fetch it only when the user completes all the recommended steps.
React 16.6.0 (or higher) provides a simple api which helps render lazy components called React.lazy and React.Suspense. Let’s look at this simple example:
We have a component here which renders a divand in it the Suspensecomponent which wraps the OtherComponent. If you look at line 1 you will see that OtherComponent is not brought directly. Usually it will look like this: import OtherComponent from './OtherComponent';
Instead, the import command is used as a function which receives the path of the file. This works because Webpack has built-in code splitting, and when used in this specific way returns a promise which will resolve with the content of the file once it’s fetched. This import is wrapped in the React.lazy function.
In our render function of MyComponent the OtherComponent is wrapped in React.Suspense which has a prop called fallback. This means that only when the render function “gets” to the OtherComponent (line 7) it will begin fetching it. In the meantime, it will render whatever is in rendered in the fallback prop. In this example, a div with the text Loading...
That’s it… “it just works”…
There are two “gotchas” you should take into consideration:
The component which is brought in lazily has to have a default export and that will be the entry point of your component. You can’t use a named export.
You have to wrap the React.lazy component with the React.Suspense component and you have to provide it with the fallback prop, otherwise, an error will be thrown. But don’t worry, in case you don’t want to render anything until the lazy component arrives, you can just pass null as the fallback prop.
Did it Work Out For me?
It did! Well, kind of… The part that worked marvellously was the code splitting. Let’s look at the Webpack analysis after splitting the code:
As you can see in the image above, my bundle has been cut down by about 50% to 96KB. YEY!
So what didn’t work? The positioning of my tooltip was now off:
The problem was that I “told” the tooltip to open by setting a state in the React component, in the meantime, I rendered null (nothing) using the React.Suspense component. Once the content arrived lazily, it was rendered into the dom. However, the positioning of the tooltip was already done beforehand and because the props of the tooltip component did not change, it didn’t “know” that it needed to check if needs to re-position the content. If I changed Chrome’s window size, the tooltip “popped” into the right position because the tooltip was listening to prop changes and window resizes in order to initiate re-positioning.
So what was the solution here? Cut out the middle-man.
I needed to first fetch the lazy component and only then set the state which “told” the tooltip to open. I was able to do this by using the same Webpack code-splitting ability but without wrapping it in React.lazy:
This function is called after my component get’s triggered via websockets that it needs to show the “happy moment”. I am using Webpacks import function (lines 2–4). If you remember what I wrote earlier, it returns a promise so I can use the async/await syntax.
Once the component arrives, I am setting it to the instance of my component (line 5) so I will be able to use in the render function later. Notice also how I can use named exports now, I am using the one called SidebarHappyMoment (line 5).Last but not least, I am “telling” the tooltip to open by setting the state after I know my component is ready (lines 6–8).
My render function now looks like this:
Notice how in line 3 I am rendering this.SidebarHappyMoment which I’ve set on my instance earlier. This is now a normal synchronous render function like you’ve used a million times before. And now, my “happy moment” tooltip rendered exactly where it should have because the tooltip was opened only after it’s content was ready.
The Product Defines The Architecture
The product defines what needs to be visible and interactive when the component first renders. This will help you as a developer figure out what you can break apart and bring in later as needed. I gave my specific use case more thought and “remembered” that once the user completes the setup steps or if he is not the site’s admin, we don’t want to render the progress bar at all. Using this information, I was able to split my bundle even more and now it looks like this:
As you can see the bundle size is now only 38KB. Remember we started with 190KB? An 80% reduction. I have already recognized more things that I can extract and I am eager to trim the bundle even more.
Developers tend to stay in their “comfort zone” and not delve beyond the code and it’s functionality. However, using these tools, some creative thinking and working closely with your product manager you could probably enhance your app’s performance by making your bundle size much smaller.