loading...
Cover image for How we reduced our initial JS/CSS size by 67%

How we reduced our initial JS/CSS size by 67%

goenning profile image Guilherme Oenning Updated on ・8 min read

We have been working on reducing the amount of bytes that we send to all Fider users. Being a web application built with React, we have focused on JS and CSS. On this post we share our learnings, some concepts and suggestions on how you can do the same with your web application.

Fider is built with React and Webpack on the frontend, so the topics below will be mostly useful for teams using same stack, but the concepts can also be applied to other stacks. It is also an open source, so you can actually see the Pull Requests and the source code: https://github.com/getfider/fider

Table of Contents

Webpack Bundle Analyzer

webpack-bundle-analyzer is a webpack plugin that generates an interactive zoomable treemap of all your bundles. This has been crucial for us to understand which modules are inside each bundle. You can also see which are the biggest modules within each bundle.

If you don’t know the root cause, how can you tackle it?

This is an example of what this plugin will generate for you.

Did you notice that huge entities.json inside the vendor bundle? That's a good starting point to analyze the content of your bundle.

Long term caching with content hash

Long term caching is the process of telling the browser to cache a file for a long time, like 3 months or even 1 year. This is a important settings to ensure that returning users won’t need to download the same JS/CSS files over and over again.

The browser will cache files based on its full path name, so if you need to force the user to download a new version of your bundle, you need to rename it. Luckly webpack provides a feature to generate the bundles with a dynamic name, hence forcing the browser to download new files only.

We have previously used chunkhash for a long time on our webpack configuration. 99% of the cases where you want long term cache, the best option is to use contenthash, which will generate a hash based on its content.

This technique does not reduce the bundle size, but it certainly helps to reduce the amount of times the user has to download our bundles. If the bundle didn’t change, don’t force the user to download it again.

To learn more, visit the official documentation https://webpack.js.org/guides/caching/

The common bundle

Combining all the NPM packages into a separate bundle has been a long time practice for many teams. This is very useful when combined with long term caching.

NPM packages change less often than our app code, so we don’t need to force users to download all your NPM packages if nothing has changed. This is usually called the vendor bundle.

But we can take this practice one step further.

What about your own code that also change less often? Maybe you have a few basic components like Button, Grid, Toggle, etc. that have been created some time ago and haven't changed in a while.

This is a good candidate for a common bundle. You can check this PR #636 where we basically move all our own modules inside some specific folders into a common bundle.

This will ensure that, unless we change our base components, the user won’t need to redownload it.

Code Splitting on route level

Code splitting is currently a hot topic. This has been around for some time, but the tools and frameworks have evolved a lot, to the point where doing code splitting is much simpler now.

It’s very common to have applications that push one big bundle that contains all the JS/CSS required to render any page within the application, even if the user is only looking at the Home page. We don’t know if the user will ever visit the Site Settings page, but we have pushed all the code for that already. Fider has been doing this for a long time and we now have changed it.

The idea of Code Splitting is to generate multiple smaller bundles, usually one per route, and a main bundle. The only bundle we send to all the users is the main bundle, which will then asynchronously download all the required bundles to render the current page.

It seems complicated, but thanks to React and Webpack, this is not rocket science anymore. For those using React <= 16.5, we recommend react-loadable. If you’re already on React 16.6, then you can use React.lazy() which has been a new addition to this version.

  • In this PR you can find how @cfilby (thank you!) added code splitting to Fider with react-loadable: PR #596
  • After we migrated to React 16.6, we have then replaced this external package with React.lazy and Suspense: PR #646

We also had issues with some rare events where users were having issues to download asynchronous bundles. A potential solution has been documented on How to retry when React lazy fails.

Edit 4th Dec: You might also consider using loadable as per Anton's comment.

Loading external dependencies on demand

By using the Webpack Bundle Analyzer we noticed that our vendor bundle had all the content of react-toastify, which is the toaster library that we use. That is usually ok, except that 95% of the Fider users will never see a toaster message. There are very few places we show a toaster, so why do we push 30kB of JavaScript to every user if they don’t need it?

This is a similar problem to the one above, except that we are not talking about routes anymore, this is a feature used in multiple routes. Can you code split on a feature level?

Yes, you can!

In a nutshell, what you have to do is switch from static import to dynamic import.

// before
import { toast } from "./toastify";
toast("Hello World");

// after
import("./toastify").then(module => {
  module.toast("Hello World");
});

Webpack will bundle the toastify module and all its NPM dependencies separately. The browser will then only download that bundle when the toast is needed. If you have configured long term caching, then on the second toaster call it won’t have to download it again.

The video below shows how it looks like on the browser.

You can see the details on how this was implemented on PR #645

Font Awesome and Tree Shaking

Tree Shaking is the process of importing only what you need from a module and discarding the rest. This is enabled by default when running webpack on production mode.

The usual approach to use Font Awesome is to import an external font file and a CSS that maps each character (icon) on that font to one CSS class. The result is that even though we only use icon A, B and C, we are forcing the browsers to download this external font and a CSS definition of 600+ icons.

Thankfully we found react-icons, a NPM package with all free Font Awesome (and other icon packages too!) in a SVG format and exported as React Components on a ES Module format.

You can then import only the icons you need and webpack will remove all other icons from the bundle. The result? Our CSS has is now ~68kB smaller. Not to mention that we don’t need to download external fonts anymore. This change was the biggest contributor on reducing the CSS size on Fider.

Want see how? Check out this PR #631

Switching from big to small NPM packages

"NPM is like a lego store full of building blocks that you can just pick whichever one you like. You don’t pay for the package you install, but your users pay for the byte size that it adds to your application. Choose wisely." - @goenning

While using the Bundle Analyzer we found that markdown-it alone was consuming ~40% of our vendor bundle. We have then decided to go shopping on NPM and look for an alternative markdown parser. The goal was to find a package that was smaller, well maintained and had all the features we needed.

We’ve been using bundlephobia.com to analyse the byte size of any NPM package before installing it. We have switched from markdown-it to marked, which reduced ~63kB from our vendor bundle with minimal API change.

Curious about it? Check out PR #643.

You can also compare these two packages on bundlephobia:

Think twice before adding a large package. Do you really need it? Can your team implement a simpler alternative? If not, can you find another package that does the same job with less bytes? Ultimately, you can still add the NPM package and load it asynchronously like we did with react-toastify mentioned above.

Optimising the main bundle is crucial

Imagine that you have an application doing code splitting by route. It’s already running in production and you commit a change to your Dashboard route component. You might think that Webpack will only generate a different file for the bundle that contain the Dashboard route, correct?

Well, that’s not what actually happens.

Webpack will ALWAYS regenerate the main bundle if something else changes in your application. The reason being that the main bundle is a pointer to all other bundles. If the hash of another bundle has changed, the main bundle has to change its content so that it now points to the new hash of the Dashboard bundle. Makes sense?

So if your main bundle contains not only the pointers, but also a lot of common components like Buttons, Toggle, Grids and Tabs, you’re basically forcing the browser to redownload something that has not changed.

Use the webpack bundle analyzer to understand what’s inside your main bundle. You can then apply some of the techniques we’ve mentioned above to reduce the main bundle size.

TSLib (TypeScript only)

When compiling TypeScript code to ES5, the TypeScript Compiler will also emit some helper functions to the output JavaScript file. This process ensures that the code we wrote in TypeScript is compatible with older browsers that doesn’t support ES6 features like Classes and Generators.

These helper functions are very small, but when there are many TypeScript files, these helper functions will be present on every file that uses a non-ES5 code. Webpack won’t be able to tree shake it and the final bundle will contain multiple occurrences of the very same code. The result? A slightly bigger bundle.

Thankfully there’s a solution for this. There is a NPM package called tslib that contains all the helper functions needed by TypeScript. We can then tell the compiler to import the helper functions from the tslib package instead of emitting it to the output JavaScript file. This is done by setting importHelpers: true on the tsconfig.json file. Don’t forget to install tslib with npm install tslib —save.

That’s all!

The amount of bytes this can reduce from the bundle will depend on the amount of non-ES5 files, which can be a lot on a React app if most of the Components are classes.

The next billions users

Are you ready for the next billion users? Think about all the potential users of your app that currently struggle to use it on a low-cost device and slower network.

Reducing the byte size of our bundles has a direct impact on the performance of our applications and can help us make it more accessible to everyone. Hopefully this post can you help on this journey.

Thank you for reading!

Discussion

markdown guide
 

One small suggestion I have is to load React from a CDN, and put it in a separate bundle otherwise (as a fallback). React is very popular, and loading it from a CDN means that it's more likely to be cached - if your users have already hit another site that used the same version of React, from the same CDN.

You're likely not upgrading your React version as often as you're upgrading your other vendor packages, which is why React should be separate. Otherwise, whenever you change any of the other vendor packages, users will need to download the whole of React again.

 

Great suggestion, thank you! The advantages are clear, but what about the disadvantages? Do you know any? I wonder why this is not more popular on apps using React.

 

Won't the other vendor packages expect react as a peer dependency? For example react routerdom

You mean the common bundle? It does depend on vendor bundle (which contains React and reactdom), which is why on the html we import the vendor bundle before the common

And what about Server Side rendering then? While doing SSR, I need react's renderToString function on server so I have to install react anyways. I am curious to know how did you do it?

I don’t have Node.js on the server side, so SSR is very hard to me. So I’m planning to use puppeteer to do prerender for crawlers.

You don't necessarily need Node.js for server side rendering. It's likely your preferred server side language has some JavaScript engine you could use.

 

Great write-up, Guilherme! Love that the diffs for each improvement are publicly available for folks to learn from. Did you use any other tools while auditing your app? (e.g Lighthouse) 😀

 

I did run it sometimes, but haven't stored it. I'll boot up both versions, compare the lighthouse result and post here later today! :)

 

Before: googlechrome.github.io/lighthouse/...

After: googlechrome.github.io/lighthouse/...

This is client-side rendered, so there's still room to improve.

 

I wonder why you haven't mentioned using brotli in addition to gzip.
It produces far better compression ratio and decompression speed is off the hook.
It's supported by all major browsers (See caniuse.com/#feat=brotli) and has a webpack plugin (See github.com/mynameiswhm/brotli-webp...).

 

Hi Omer, I haven't mentioned that because we didn't implement it yet. But we're definitely interested on that too! 😀 Are you using Brotli already? Have you seen much difference?

 

I have used brotli in the past.
It's such a huge boost and it's super easy to implement.

 

I believe tree shaking is actually disabled in webpack if it finds you are using dynamic imports as it doesn't know (or doesn't go to the trouble to know) all the parts of a module different parts of your code will need.

With your react-icons usage you are imply importing just what you need. However, it appears that with v3 you will still have to import the complete icon set as the icons can no longer be imported individually (they come in a single file).

 

We're not using dynamic imports on react-icons, so Webpack can still tree shake it. Our react-icons is already on v3 and our bundles only have the SVG for the icons we use. Have you had a different outcome with this setup?

 

interesting, maybe there are situations where it will work and I read too much into this.. github.com/webpack/webpack/issues/...
I was definitely having issues and I guess I assumed this applied across the board (especially as we're doing code splitting on routes in the main component).

 

You can use archer-svgs to load svg async and cache it in localStorage, when you reuse svg without http request!Remove svgs from your js-bunilde and Thin your js-bundle forever!(eg: Dont reload 100kb svg bundle only for 1kb svg update!)

 

There is only one mistake in this article - react-loadable.
It's unmaintained for a long time already, and should not be used at all (github.com/jamiebuilds/react-loada...). It's better to say - it always was unmaintained.

Lazy and Suspense are also not quite "done" yet, so please consider "loadable" - github.com/smooth-code/loadable-co...

 

I wasn't aware of that package, thanks for sharing. I've add a note on the post so others can have a look a that too.

 
 

Amazing article on reducing bundle size from a broad range of perspectives.

 
 

I think that the other way to stop changing your main bundle whenever some other bundle changes is to create a runtime chunk separately. This chunk will contain all runtime info that was inside main bundle till yet. I have also written about some techniques to fine-tune your bundle size using webpack medium.com/@poshakajay/heres-how-i...

 

I've seen a wepback plugin that lazily loads css. I'm not sure how that works or whether that's possible. But one of my colleagues showed it to me. Do youveyany experience with that?

 

We only lazy load the css from the page splitting. But our main css bundle is always loaded synchronously. My understanding is that when lazy loading the whole css, if there’s a delay to download it, the user will first see an ugly and broken layout

 

thanks for your thorough summary :). Now I learn a new tool bundlephobia.

 

This is a great article, thanks for sharing too. I particularly like the bundle analysis idea I've been doing that lately, really helps prevent shipping large builds.