How we slashed our App loading time in half
Problem
We are continuously adding new features to our app, which requires the addition of more libraries and code. As a result, the React JS bundle becomes bloated after each feature implementation.
This is how our initial JS bundle looked:
Size (Gzipped): 6.6 MB
P50: ~Avg 1s
P75: ~Avg 2s
P95: ~Avg 10s
Step 1 — Analyze Bundle for Problems
To see in-depth details of the bundle, we used a tool called Webpack Bundle Analyzer. This will help you:
- Realize what’s really inside your bundle
- Find out what modules make up the most of its size
- Find modules that got there by mistake
- Optimize it!
From the above image, few things were clearly evident which were responsible for bloating of our JS bundle.
- react-icons
- monaco-editor
- Lottie Animations
So lets see how we improved each one of them.
Step 2 — Optimizing Bundle
react-icons tree shaking
The webpack bundle analyzer revealed that react-icons was including whole icon packs, even though we were only using 1–2 icons from each pack. This is how size of icons packs looked like
react-icons/si
: 8.6%react-icons/md
: 4.5%react-icons/fa
: 3.5%react-icons/tb
: 3.1%react-icons/ri
: 2.9%
… = Total 50% of Bundle (Gzipped)
Overall, this accounted for approximately 50% of the total JS bundle size. To address this issue, we followed the approach mentioned in the react-icons repository. This helped us in including only those icons in the bundle which we were using.
Here’s link to the PR https://github.com/requestly/requestly/pull/931
Result: Bundle Size: 6.6MB -> 3.4MB (-50%)
Bundle Splitting
The analyzer output above indicates that two things are taking up a large bundle size:
- Lottie Animations: 454.88 kB/3.4MB (13%)
- CodeEditor: 803.06 kB/3.4MB (23%)
These dependencies can be easily extracted into their own bundles using Bundle Splitting.
What is Bundle Splitting?
Bundle Splitting lets you defer fetching component’s code until it is rendered for the first time. This can be accomplished in React using lazy and suspense, along with Code Splitting in a bundler like Webpack.
First Try
We added the following code in our app to achieve this
import React, { lazy, Suspense, ComponentType } from "react";
import PageLoader from "components/misc/PageLoader";
type ImportFunction = () => Promise<{ default: ComponentType }>;
const lazyload = (importFunction: ImportFunction, fallback = <PageLoader />): React.FC<any> => {
const LazyComponent = lazy(importFunction);
const component = (props: any) => (
<Suspense fallback={fallback}>
<LazyComponent {...props} />
</Suspense>
);
return component;
};
export default lazyload;
And updated import for lazily components
import lazyload from "utils/lazyload";
export default lazyload(() => import(/* webpackChunkName: "FileViewerIndexPage" */ "./FileViewerIndexPage"));
Roadblock 🚧
By implementing Bundle Splitting, we were able to significantly reduce the size of our app’s bundles. However, this approach had its own drawbacks.
We use Firebase Hosting to deploy our app. Whenever we deploy a new version of the app, if a user is on an old version and tries to fetch a code-splitted bundle that no longer exists, it leads to a ChunkLoadError (missing: (5d7762dfa285ec18db7e)
error.
Possible Solutions
- Increase Cache TTL (Not 100% reliable) :We tried increasing the TTL for the JS bundle to 7 days, but this only partially worked and had problems with old bundles that were never fetched before and caused
ChunkLoadError
- Keep old deployment bundles hosted along with the new version (100% reliable, but increases hosting size): This approach is 100% reliable, but it can significantly increase the hosting size.
- Refresh the page whenever there’s a new main JS bundle available: This solution is 100% reliable, but it can negatively impact the user experience when you do 5–10 deployments per day 😛 .
- Refresh the app whenever it throws a
ChunkLoadError
.
Final Solution
We decided to go with the 4th Solution. So whenever ChunkLoadError
appears in the app, the page automatically refreshes to fetch the latest bundles.
This solution is 100% reliable and minimizes page refreshes, as it only refreshes the page in case of errors instead of refreshing every time. Here is the code we added to implement this solution.
Here’s the link to the PR https://github.com/requestly/requestly/pull/898
import React, { ComponentType } from "react";
import PageLoader from "components/misc/PageLoader";
import lazyload from "./lazyload";
type ImportFunction = () => Promise<{ default: ComponentType }>;
// Use this only for route based lazy imports
const lazyWithRetry = (importFunction: ImportFunction, fallback = <PageLoader />): React.FC<any> => {
const LazyComponent = lazyload(() => {
return new Promise((resolve, reject) => {
// check if the window has already been refreshed
const hasRefreshed = JSON.parse(window.sessionStorage.getItem("retry-lazy-refreshed") || "false");
// try to import the component
importFunction()
.then((component) => {
window.sessionStorage.setItem("retry-lazy-refreshed", "false"); // success so reset the refresh
resolve(component);
})
.catch((error) => {
if (!hasRefreshed) {
// not been refreshed yet
window.sessionStorage.setItem("retry-lazy-refreshed", "true"); // we are now going to refresh
return window.location.reload(); // refresh the page
}
reject(error); // Default error behaviour as already tried refresh
});
});
}, fallback);
return LazyComponent;
};
export default lazyWithRetry;
Updated Imports
import lazyWithRetry from "utils/lazyWithRetry";
export default lazyWithRetry(() => import(/* webpackChunkName: "FileViewerIndexPage" */ "./FileViewerIndexPage"));
Result:
Lottie Animations: 3.4 MB -> 2.95 MB (-454.88 kB)
CodeEditor: 2.95 MB -> 2.15 MB (-803.06 kB)
CodeEditor: 2.95 MB -> 2.15 MB (-803.06 kB)
Results
Initial
Size (Gzipped): 6.6 MB
P50: ~Avg 1s
P75: ~Avg 2s
Final
Size (Gzipped): 2.15MB
P50: ~Avg 400ms
P75: ~Avg 1s
So that’s how the bundle size is down to 1/3rd of the initial bundle and latencies are down to half 😄🚀