Why is bundle size important?
Because that affects how quickly your app will load when a user opens your page. It is vital because many users will probably be connecting from dodgy 3G or wireless connections, where the speed is slow, and thus a small bundle size is essential so that users don’t leave your site. Users tend to leave a site if a page takes longer than 3 seconds to load. The 2-second threshold is the “danger zone” where most users expect the app to be fully loaded within that time and begin to get impatient if it isn’t.
Granted, React app loading is not symmetrical to page loading - generally, you can load a bunch of HTML and CSS much faster than a React.js bundle file. However, load time is still important even though you have a slightly longer amount of time to render the app. So while users will forgive you for your app taking 10 seconds to render, the same cannot be said for 60 seconds, 45, and possibly even 30 seconds.
Nobody’s expecting you to render a React app in 2 seconds, though if you can, then your team should throw a pizza and beer celebration. For the rest of you, here are some techniques to shrink the bundle size.
Split your bundles into smaller ones
This is a very powerful technique to make the app load faster because instead of one large bundle, it is now a bunch of smaller ones that Webpack can load on-demand. So, you can package your app’s dashboard as a bundle that loads immediately and delay the loading of bundles representing other auxiliary pages. I imagine this is what Facebook, Instagram, and others use to keep the load time of their main sites - which are written in React - manageable.
Splitting bundles is a feature available since Webpack 4. Apps made nowadays likely are not building using Webpack 3 or lower, so there should be no worries about upgrading to a slightly incompatible version.
How does code-splitting work?
The Webpack documentation gives us 3 methods to implement code splitting. The first one uses entry points using entry
configuration lines in the Webpack config. This basically means that each component tree you want to split off has some ancestor component in a specific file referenced in the Webpack configuration. The entire tree is bundled down to a single bundle.
This is how you use entry
to define different bundles Webpack needs to make:
const path = require('path'); | |
module.exports = { | |
mode: 'development', | |
entry: { | |
index: './src/index.js', | |
another: './src/another-module.js', | |
index: { | |
import: './src/index.js', | |
dependOn: 'shared', | |
}, | |
another: { | |
import: './src/another-module.js', | |
dependOn: 'shared', | |
}, | |
shared: 'lodash', | |
}, | |
output: { | |
filename: '[name].bundle.js', | |
path: path.resolve(__dirname, 'dist'), | |
}, | |
}; |
You must include dependOn: 'shared'
for all the bundles, and then list any external libraries you’re importing as dependencies and the file name of each component used by the multiple component trees. Otherwise, the shared dependencies are duplicated in both bundles and defeat the purpose of code splitting. The lodash dependency in this example will occupy more than 500KB in each of the created bundles without shared dependencies.
Of course, it is usually not feasible to place all your shared components in one file. Whatever you write in the dependOn:
the directive will have a key right below the entry
object, such as shared
in this example, and is an array of strings if a bundle has multiple dependencies. Creating multiple dependOn
names for different bundles allow you to define multiple shared entry points whose paths reflect the structure of your React app.
Refactor long lists of content as separate XHR calls
If you have any long arrays of text strings in your React app, these might be weighing down the load time. Try to make an API endpoint to supply this data, then use node-fetch
to retrieve it at runtime, using a progress indicator as a placeholder while the request completes. You can use this alongside code splitting to fetch the content before additional bundles are loaded, which reduces the time to render before a user can interact with the app.
The react-window module has been designed to fetch long lists of content. It has an additional performance optimization, however. Instead of fetching the entire list, it only fetches the amount that fits in the viewport and then issues a DOM update. This is useful if, for some reason, your list, along with all its properties, is several megabytes large. It happens sometimes.
Additionally, you might be able to set up your API endpoint to prefetch the request, which will make the server cache the response by the time you’re ready to issue the actual API call. In some cases, this can speed up the time it takes to fetch long lists of content.
Use tree-shaking
Tree-shaking is a process that eliminates dead code from bundles. For this to work, you must only import the functions you need from modules (i.e., don’t import the whole thing), and you have to place "sideEffects": false
in your package.json on the same level as the name
property. You can also add it in the Webpack configuration file under the rules
property object.
A side effect is any module that, when imported, runs some background function in addition to importing items from the module. Webpack wants to make sure that removing unused functions from the bundle does not accidentally inhibit important code from running. If there are such modules, you should include their file names as an array of strings in the sideEffects
property and Webpack will keep those in the bundle.
Note that for this to work, you have to use ES2015 import syntax in your files.
Use service workers
Applicable to all kinds of web apps, not React apps per se.
A service worker is a Javascript file that a page deploys in the background. It “installs” this file by caching all files specified in the “install” event listener. Then, it communicates with the page by sending a `window.postMessage()“ call, whose data is then intercepted by the “message” event listener on the webpage.
But how does a service worker know which page to communicate with? It turns out that postMessage()
also takes an origin parameter that tells the browser which pages it should broadcast the message to. So tabs in a browser window that have the same origin will all receive the message.
So service workers don’t really do one-to-one messaging unless there’s only one matching page. Think of it as a publish-subscribe channel where all open pages of the same origin will get the data received in the message. Remember that an origin is a tuple of hostname or domain name, port number, and protocol (HTTP or HTTPS).
Service workers can improve app performance by caching the files specified at install time and then returning them in the “message” payload to open the page. These files are effectively cached in the browser, so apps can use this method to read items such as CSS files, fonts, and other dependencies defined in the HTML, such as <script>
tags. It doesn’t work well for caching bundles (use the Webpack server instead), Also without specifying the origin, you create security holes in your app.
Google Developers has some terrific code samples for service worker events in their documentation. They also have an older tutorial that explains how service workers work.
I hope this post has benefited you in your quest to make your React app faster. If you have any other performance ideas, let me know about them in the comments below.
Cover Image by @bobbinio2112 via Twenty20