How to utilize code-splitting and preloading to build a fast, responsive web app 🚀

Making interactive web apps today is easier than ever before, with the prevalence of client-side Javascript frameworks like React, Angular, and Vue. Since the entire code for a web app can be shipped to the client, navigating through pages on the site can be lightning fast since it doesn’t require a round-trip to the server.

This is also a double-edged sword. Since the entire application is shipped to the client, users have to download the code for pages and functionality that they may never use. A user landing on marketing pages may not need the code for the hotel booking checkout page. This can particularly be a costly problem on users with limited or metered connections, as the landing pages may take longer to download than they should which could turn into page abandonment.

One solution to this problem is code splitting. It is supported by all major Javascript bundlers. It allows us to write code like this:

// expensive-file.js
export function hugeFunction() {
  // ...
  return “expensive result”
}

// main.js
import(“./expensive-file”).then(expensiveFile => {
  console.log(expensiveFile.hugeFunction())
})

This uses dynamic imports to import a file which returns a promise. The bundle then requests the file (expensive-file.js) and resolves the promise after the file is retrieved. In the case of web apps, this involves making a network request to the server to download expensive-file. What this now does is allow the file to be downloaded only when it’s needed, not at the start of the app.

Introducing React.lazy

React provides a wrapper around dynamic imports which allow components to be lazy loaded using Suspense, a declarative way to provide component loading fallbacks. Here’s what that looks like:

// ExpensiveComponent.jsx
export default () => <div>I’m an expensive component</div>

// App.jsx
import React from “react”
const ExpensiveComponent = React.lazy(
  () => import(“./ExpensiveComponent”),
)

export default () => (
  <div>
    <React.Suspense fallback={<Loader />}>
      <ExpensiveComponent />
      This text will be shown after
      ExpensiveComponent is loaded.
    </React.Suspense>
  </div>
)

When <ExpensiveComponent /> is rendered, the dynamic import will fetch the code from the server. In the meantime, the “suspense fallback” will be displayed, or in this case, <Loader />.

Wonderful! This allows us to split our app up into smaller bundles and only ship necessary code to the client. At Hopjump, we split bundles by route since that’s a natural split.

A Jarring User Experience

One problem that we noticed with code splitting with suspense was the jarring experience when clicking on a link immediately led to a spinner. Take a look at this example:

A plain spinner is loaded flashes on the screen, then the search results page is shown

This happens because the user navigates to a page whose code is not yet downloaded. React then kicks off the dynamic import to download the code, and in the meantime we show the spinner.

However, we can do better. We know that users on certain pages have a very high chance of going to a particular page next. For instance, there’s a high chance that users who are on our search form page will go to the search results page next. This means we can “preload” the search results page once the user is on the search form page.

Preloading routes with route maps

Our solution to this problem involves creating what we call a “prefetch map”, which is pairing of pages to other pages which should be preloaded. For example, the search form page links to the search results page, or the hotel booking page maps to the booking confirmation page.

const SearchResultsPage = () => import(“./SearchResultsPage”)
const ConfirmationPage = () => import(“./ConfirmationPage”)

const prefetchMap = [
  {
    path: paths.HomePage,
    prefetchComponents: [SearchResultsPage],
  },
  {
    path: paths.HotelBookingPage,
    prefetchComponents: [ConfirmationPage],
  },
  // ...
]

The contents of this map depend heavily on your particular site usage patterns. Also, you can preload more than one page if there is a high likelihood that users transition to multiple pages. We then use this preload map with a custom React hook like such:

import { useLocation, matchPath } from "react-router"
import { useEffect } from "react"
import prefetchMap from "./prefetchMap"

const WAIT_MS = 2000

const usePrefetchPages = () => {
  // Listen to the current “location” or url
  const location = useLocation()
  // See if there are any pages to be preloaded
  // based on the current url
  const prefetchConf = prefetchMap.find(({ path }) =>
    matchPath(location.pathname, { path, exact: true }),
  )
  
  useEffect(() => {
    if (prefetchConfig) {
      // Prefetch after a delay so we don’t slow down
      // more important network requests.
      const id = setTimeout(() => {
        prefetchConf.prefetchComponents.forEach(component => {
          try {
            // This calls the dynamic import() statement,
            // which triggers the network request.
            component()
          } catch {
            // Not a big deal. Probably trying to fetch
            // an old version of the chunk.
          }
        })
      }, WAIT_MS)
      
      // Effect cleanup
      return () => {
        clearTimeout(id)
      }
    }
  }, [prefetchConfig])
}

We include this hook at the top level of our app, and it preloads the code for future pages based on the url of the current page. It loads after a delay so that we don’t eat up resources needed for higher priority network requests. Also, it calls the component’s import statement, but does not use the component so there are no changes to the UI. Now, when the user actually transitions to the next page, there are no fallback spinners because the component’s code is already downloaded.

One more thing – preloading page chunks only works for the chunks you explicitly have in the prefetch map. If you have secondary components that are loaded within their own Suspense boundaries, they won’t be prefetched when the page component is prefetched. In some cases this might be desirable, but in other cases you might want to prefetch those secondary components as well.

One approach to solving this would be to prefetch those components explicitly by adding them to the prefetch map. Another approach would be to leverage Webpack’s webpackPreload import comment, which will eagerly fetch the pack whenever it’s parent component is fetched. Here’s what that looks like:

// ExpensivePage.jsx
const expensiveLibrary = import(
  /* webpackPreload: true */
  “expensive-library”,
)

Now, whenever ExpensivePage is prefetched, expensive-library will be prefetched as well. Let’s see how prefetching affected our app:

The search results page instantly loads after clicking search

Hooray! This user experience is much better!

This approach isn’t perfect, as we can’t guarantee that users are going to transition to a particular page, but it’s much better than it was! If a user transitions to a page we weren’t expecting, they’ll continue to get the spinner fallback.

I hope this helps others make code splitting easier while still maintaining good UX!

This approach was inspired by Maxime Heckel’s blog post “React Lazy: a take a preloading views”.