10 Tips & Tricks for smaller bundles in React apps

Aggelos Arvanitakis
ITNEXT
Published in
10 min readSep 27, 2019

--

In this article I’ll attempt to share a few tips & optimization tricks that you should consider when aiming to minimize the footprint of your React app. I’m confident that after reading this article you will be able to reduce your bundle size by at least 5–10%, since I’ll start with conventional tips and move on to edge-case micro-optimizations. I also want to point out that most of the things that I’ll go through are not React specific, but apply to all JS apps that are built using Webpack.

Why should I care?

If you’ re wondering that, then the sooner your JS reaches your user’s browser, the sooner your React app will boot and the quicker the user will be able to fully interact with your app. Especially in B2C apps, a few hundred milliseconds saved from TTFB or FCP, can sometimes generate millions in company revenues. This thing is so important, that companies pay performance engineers big bucks to get advice on how to make their website faster. In addition, you are also respecting your user’s data plan, making sure they don’t consume too many MB when visiting your website, while also ensuring that their — sometimes — poor network doesn’t hinder their ability to access it. Generally, the sooner the users see something on their screen, the better. If you are doing SSR, then that shouldn’t affect you as much, but it will still cause issues to your users since the HTML that you shipped from your server is not interactable until the JS bundle hydrates it.

I can go on forever, but enough with the jibber jabber. I promised you tips so let’s jump straight in:

1. Code Minification

This is obvious but I had to include it. If you are using create-react-app then you are doing everything correctly. If you’re not, then make sure you minify your JS, JSON & HTML and hash/shorten your CSS class names.

2. Prefer Functions with Hooks than Classes

Classes tend to have a lot of additional boilerplate, while hooks are there so that you can achieve the same thing with less code. Less code = smaller bundles. Keep this in mind if you are part of a large scale app.

3. Prefer Preact instead of React

In those rare cases where your app is not using any fancy React API, you can use Preact instead of React. This is a lighweight version of React that’s almost 90% smaller, but can still do most stuff that React itself can. Click here for a full list of the features it supports.

4. Precise Code Transpilation

If you are using Babel to transpile your code, make sure that you only target the browsers that your users use. A lot of projects have configured babel to generate code that’s compatible with older browsers, which results into code verbosity. Make sure to specify browserlist according to your needs or have babel explicitly generate code for the newest generation of browsers (refer to your user analytics) and it will surely reduce your JS footprint.

5. Avoid importing the whole library when it’s not needed

If you are using heavy libraries that aren’t tree-shakeable, make sure that you only import exactly what you need. For example, if you are using lodash just for the get function, then instead of writing import { get } from 'lodash' you should write import get from 'lodash/get' . The former will import all the modules of the library, while the latter will only import get and nothing else. Specifically for lodash, there is a webpack plugin that will convert the former type of import to the latter one, so that you don’t have to think about things like that while developing. Generally, I would recommend doing it manually in order to get used to it, since you’ll eventually have to do it for other libraries that don’t have a corresponding plugin available.

6. Choose the smallest library that satisfies your needs

If you are working with a lot of other developers in a project, chances are that some of them may include the 3rd-party libraries that they themselves are familiar with. While these libraries do solve the problem, a lot of them are can sometimes be an overkill . Take for example moment, a datetime manipulation library which is a whopping 66KB (!) when gzipped. Most of the times, people use moment just to display a date in a pretty format. An alternative could have been dayjs which is less than 2KB when gzipped and could potentially equally solve the issue at hand.

To investigate whether an existing project is prone to a case like the one described above, you can use Webpack’s bundle analyzer plugin which will display a treemap of all of your packages. See something that is too large on the treemap? Check the code and see how it’s being used. If you only use it for a trivial task then:

  1. Check if you can write vanilla JS to solve the issue.
  2. If not, check if you can find an alternative library to suit your needs. To check if the alternative is a good candidate, type its name in Bundlephobia and see if there are (footprint) gains to be made from switching to it.
  3. If there isn’t any alternative (or the alternatives are equally heavy in size), ask yourself how much value does the feature that uses the package add to the project. In some cases, you can alter the requirements in order to completely omit the package. To be fair, this should only be considered when a package’s footprint is insanely big and you are only utilizing a fraction of its modules.

7. Split the code in multiple bundles (a.k.a. code-splitting)

Let’s get this out of the way, shall we? If you are not doing it, do it. If you already doing it, ask yourself whether you are doing it optimally. To be honest, there is no “right way” of doing code-splitting, but as long as you don’t load more than what you need to render the page, then you are good to go. Although there are different approaches that suit each business, the two (2) core ones are:

  • Route-based code-splitting
  • Feature/component-based code splitting

The first one creates one bundle for each route (each different page in your app) and is your best approach when the re-usability of React modules is not high between pages. The more “different” the pages are, the better. For example, if you have a products page and a product details page which don’t share common UI elements, then route-based code splitting might be a good bet for you. You will end up with two (2) bundles — one for each page — and life will be good. Things turn bad when you introduce a users page and a user details page which re-use the same UI elements that the product pages use. Performing route-based code-splitting in this scenario would leave you with four (4) bundles which are to one another and the network requests to fetch them might outweigh the benefits of them being split. To achieve route-based code-splitting, you can import each page of your app using React.lazy followed by a <React.Suspense /> wrapping:

For some reason, Medium doesn’t display the full code. View the full gist

Each dynamic import statement will result in a different bundle. You can make sure that two (2) or more dynamic imports get included in the same bundle by adding a nickname to a bundle and using the same nickname on the contents that you want to be grouped together. For example, in the code above, we could have added:

const ProductListPage = React.lazy(
() => import(/* webpackChunkName: "pages" */ './ProductListPage')
);
const ProductDetailsPage = React.lazy(
() => import(/* webpackChunkName: "pages" */ './ProductDetailsPage')
);

which would instruct webpack to create a single chunk (bundle) out of these two pages.

Feature-based code splitting is for apps that re-use a lot of the codebase across their pages, so the above routes-based approach wouldn’t work. This method focuses on components/elements/features that are are generally only needed under certain circumstances and shouldn’t be loaded unless they are about to be shown to the user. For example, take google’s weather widget:

Most of the times, a user that just searches something wouldn’t be seeing this, so why should google load it in its JS bundle. Why not load it only when the user is about to see it? That’s what feature-based code splitting is. To achieve it, you can use React.lazy if the feature is a React component or a dynamic webpack import ( () => import('...') ) if the feature is based on a non-React package.

Of course, not all feature should be code-split. Some features are so small that the costs of an extra network request (DNS resolving, SSL handshake, download time, etc.) outweigh the benefits of code-splitting. In order to know whether something should be code-split, visit Bundlephobia and see whether it’s worth it or not. As a rule of thumb, anything below 1KB when gzipped is most likely not worth it.

8. Optimise Compression

Are you compressing your content? If you aren’t sure, then you’re most likely not, unless your CDN does that for you automatically. Lately, CDNs like Cloudfront automatically gzip all of your content and serve it to all browsers that have gzip as a content-encoding request header. The thing is that gzip is not the best compression algorithm at the moment, since brotli results into 15–20% smaller footprint than gzip, with almost 92% global support by browsers. If you don’t care about older browsers, change your compression algorithm to brotli today, since all major CDNs are up-to-date and can properly serve it. If you do care about older browsers, keep your gzip but make sure you have a brotli-compressed bundle available for the browsers that can parse it.

If brotli compression isn’t an option (because of business or infrastructure limitations), then you can do a nifty optimization to your gzipped CSS bundles. The trick is to make sure that CSS rules are always alphabetically ordered. Gzip loves repetition and it can optimize its compression through properly indexed re-usable keywords. Having your rules ordered like that will result into 1% to 3% smaller CSS bundles. If you are feeling lazy, you can even use a webpack plugin to automate this sorting for you.

9. Include a single version/instance of each library

Suppose that you are using the latest version of apollo-client in your project. Because you work for a startup that wants to roll out a product quickly, you decide to use Amplify to handle storage, authentication, etc. Amplify internally uses apollo-client as well, but because AWS SDKs are not updating their dependencies as often, they use a specific version of the library, that’s not the latest. What you end up with, is two different versions of the apollo-client in your bundles.

One way to combat that is to make sure that you downgrade in order to use the same version as Amplify (if that’s an option). Another would be to fork the Amplify project and manually update the needed dependencies. Lastly, there is a really nasty and tricky way to combat the issue, which will only work in cases where the API of the package is hasn’t changed between releases (most of the times this translates to the major version being the same). What you can do, is use a webpack alias in order to map imports of apollo-client to the version of apollo-client that you have installed (the latest one). This means that every time Amplify tries to import the its local copy of the library, its import will be re-routed and mapped to another one. In code, that would look something like that:

... // other webpack configuration,
resolve: {
/*
make sure that all the packages that attempt to resolve the following packages utilise the same version, so we don't end up bundling multiple versions of it. Unfortunately, both `aws-appsync` and `aws-amplify` install explicit versions of the their dependencies whose minor version doesn't match. This enforces all if the packages to use the same version
*/
alias: {
'apollo-client': path.resolve(__dirname, 'node_modules/apollo- client'),
},
},
... // other webpack configuration

What this code says is “hey whenever someone does import ... from 'apollo-client' , make sure that you always resolve this import from the ./node_modules/apollo-client directory”. I want to stress that this solution might introduce problems since some other modules may not be compatible with the latest version of apollo-client , but if they are, then you can save yourself some precious KB from your bundle.

P.S. The example above was not random 😜

10. Use React only if you need to

That’s a weird one, but essentially if you don’t need React don’t ship it in the client right away. For example, you may have an app that has a landing page which most new users will initially visit. Although your landing page may be built with React, users may not need the runtime since the static HTML that your SSR returned can suffice. What I’m talking about is essentially a server-side rendered landing page without a client-side React dependency. You can then load the React runtime only when the users switch to your app. This is something that Netflix tried which resulted into a significant reduction in their JS bundle sizes.

Closing Notes

The aim of this article was to provide insight in some of the ways that you can reduce the footprint of your application, leading to quicker initial load times. Although some of those tips are not as easy to implement, I personally feel that some other are indeed trivial and can give instant value. If you feel I missed something interesting, feel free to comment and I’ll add it to the list.

Thanks for sticking until the end :)

P.S. 👋 Hi, I’m Aggelos! If you liked this, consider following me on twitter and sharing the story with your developer friends 😀

--

--