Preloading routes of React applications

Bartłomiej Jacak
SwingDev Insights
Published in
5 min readFeb 14, 2024

--

As frontend engineers, one of our main focuses is to make the products feel fast to the end users. There’s nothing more frustrating than a dozen of loaders on top of each, right? At the same time, bundles of big applications can get quite heavy. This leaves us with a challenge. How to load the app quickly, if one needs to download megabytes of data in order to render it.

To begin with, it’s important to make sure that we’re compressing the bundle. That’s a very simple and efficient strategy that makes the bundle smaller. What’s more, it doesn’t require any adjustments to the code itself. However, in case of very big projects, it’s not enough.

Having a large amount of code, we may ask ourselves — do we really need to load all of it at once? After all, most applications usually have paths that are used less frequently. How often do you visit notification settings in your bank app?

Splitting the bundle

Our job is all about solving problems. The solution to this one is to split the bundle into a couple of smaller chunks. We can group the code by usage, and load only the parts that the user needs at a specific time. That’s one great strategy, but we need to remain cautious. Too many chunks are proven to be counter-effective. You may read more in the excellent article by Harry Roberts, where he elaborates on the topic.

So how to find a good balance? There’s no single good answer. Over time, splitting the code by the application routes turned out to be a decent approach and became a standard among most modern React applications. After all, each page is an isolated part of the application, and user can only see one page at a time.

To achieve the splitting, React gives us the lazy utility. According to the documentation:

lazy lets you defer loading component’s code until it is rendered for the first time.

In other words, we create a separate bundle with the code responsible for that component and load it once necessary. Exactly what we needed.

It’s a good practice to use lazy in tandem with the Suspense. It’s being used to provide a fallback component for the time when the chunk is being downloaded.

Let’s see how this can look in practice. Suppose we have a file where the routes are defined:

const DashboardPage = lazy(() => import('./pages/Dashboard'));
const SettingsPage = lazy(() => import('./pages/Settings'));

export const routes = [
{
path: '/dashboard',
element: (
<Suspense fallback={<PageLoader />}>
<DashboardPage />
</Suspense>
),
},
{
path: '/settings',
element: (
<Suspense fallback={<PageLoader />}>
<SettingsPage />
</Suspense>
),
},
];

When importing the page components, we wrap them inside lazy to annotate that the loading will be deferred. Additionally, we use Suspense to a show a PageLoader at the time that deferred page code is being fetched.

Great, the bundle is split by the routes. You start using the app. The initial load is much quicker! Excited by the time save, you start transitioning between pages. Something’s not quite right. You notice a spinner. Then another spinner. It’s not how it was supposed to be… What’s up with these spinners?

The app bundle is split up and we no longer load all the chunks at once. It takes some time for the browser to load the necessary code of the page. Hence the loading screens.

So your aim was to make the app seem faster, but now you’re stuck with a spinner at every route change?

Preloading bundle chunks

This is where preloading comes in. To reduce the number of loading screens, we need to load the code in advance. Ideally, when the user clicks on a link, the code should already be there. You can find such functionality in frameworks like Next.js. By default, they preload each link that is present in the viewport. But what if we are not using Next? This sounds complicated to implement on our own. But fortunately, it’s not.

Preloading is nothing else than fetching the component’s bundle, keeping it in the browser’s cache, and reusing it later on. All these are achievable with a simple import declaration. That’s right, no need for complex, custom implementations. We can (and should) rely on the native behavior of lazy , import, and browser cache. We just need to glue it all together. A simple implementation could look like this:

export const pages = {
'/dashboard': {
Component: lazy(() => import('./pages/Dashboard')),
preload: () => import('./pages/Dashboard'),
},
'/settings': {
Component: lazy(() => import('./pages/Settings')),
preload: () => import('./pages/Settings'),
}
}

export const routes = Object.entries(pages).map(([path, Page]) => ( {
path,
element: (
<Suspense fallback={<PageLoader />}>
<Page.Component />
</Suspense>
),
}));

Our pages become objects with two properties: Component and preload. We use Page.Component the same way we used Page before. It’s going to render the component once needed. Page.preload() on the other hand, can be used in any place of the application to preload the component’s bundle.

The backbone of the preloading solution is there. It’s time to use it. One of the simplest ways to try this out is to add bundle preloading upon a link hover. When someone hovers over a link, we can assume they will probably want to click it to navigate to another page.

<Link href='/dashboard' onMouseEnter={DashboardPage.preload} />

Given this code, the first time user hovers over the link, we preload the dashboard page bundle in the background. Each consecutive hover won’t have any effect since the preload is either in progress, or has already finished and the bundle lives in the browser’s cache. Similarly, if the user clicks the link before the bundle downloads, no additional fetch will happen, as one is already in progress.

Next steps

The presented solution is very simple, and very manual, but can be easily improved. With the basics in place, we can extend our implementation with ease.

For example, we can create a custom Link component that will find the associated bundle automatically based on the href prop. Then preload the bundle on hover. Or mirror the Next’s behavior, and preload it when the link is present in the viewport. There are numerous options and they heavily depend on our needs and the level of control we aim for.

That’s it. The purpose of this article was to make the preloading seem less scary and to give you hints on how to start the implementation. Hope you enjoyed it :)

If this article has been interesting to you, we encourage you to explore career opportunities at SwingDev 🚀

--

--