How we slashed our App loading time in half

Sahil Gupta
Requestly Engineering
5 min readSep 4, 2023

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:

  1. Realize what’s really inside your bundle
  2. Find out what modules make up the most of its size
  3. Find modules that got there by mistake
  4. Optimize it!
Initial bundle

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

  1. 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
  2. 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.
  3. 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 😛 .
  4. 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 😄🚀

--

--