Working with React's Lazy in Production

So you've got a big client side React App, and you've decided to use code splitting to reduce its size on initial page load. Should be easy. React has some very accessible documentation regarding their lazy component loading, which works well with WebPack code splitting.

They give a really trivial example:

import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

Now, this approach works* out of the box, and the React team has been doing some excellent work exploring how the UI and UX around loading can be improved, so I'm very much excited by where this is going.

* But is painfully insufficient for production.

The main gotcha is that it can (and does) fail. The internet can be a fickle beast, especially with mobile connectivity. While the simple React example above does technically provide a mechanism to dynamically load your components, it doesn't fulfill the same contract as actually importing your code directly. Indeed not only is it possible that it can't load, but it's possible it might never load (maybe user has now gone out of service, server crashed, deployed a new version and they are unlucky trying to still get the old one).

These various complexities call for some attempted solutions.

Alerting the user to failure

Luckily the import() function returns a promise, so we could define a catch-all helper function to ensure we handle errors at least a bit.

export const errorLoading = err => {
  toastr.error(
    'An error has occured',
    'Please refresh the page. Otherwise, it will refresh automatically in 10 seconds.'
  );
  setTimeout(() => window.location.reload(), 10000);
};

So now the initial example would have have to add the catch-all:

const OtherComponent = React.lazy(() => import(./OtherComponent').catch( errorLoading));

That's already slightly better than an uncaught exception. Of course there are many approaches to the UX. You could for example use this to trigger an error boundary, or redirect to a server error page (some of the guidance from Google on SEO says that if you redirect to a server error code page, it helps them to understand).

However, it's still a bit too early to give up, surely there is a better way...

Retrying on failure to load

So one of the main things we could do, is to simply retry. Below is an example, where you can pass a function with configurable retries and delays before giving up – that itself returns a promise that will eventually resolve.

function delayPromise(promiseCreator, delayMs) {
  return new Promise(resolve => {
    setTimeout(resolve, delayMs);
  }).then(promiseCreator);
}

export function promiseRetry(promiseCreator, nTimes = 3, delayMs = 1500) {
  // Retries nTimes with a delay of delayMs on before subsequent retries
  return new Promise((resolve, reject) => {
    let promise = promiseCreator();
    for (let i = 1; i < nTimes; i++) {
      promise = promise.catch(() => delayPromise(promiseCreator, delayMs));
    }
    promise.catch(reject).then(resolve);
  });
}

So with the above our initial code would look more like this:

const OtherComponent = React.lazy(() => import('./OtherComponent').catch( errorLoading));

That's a vastly better experience than the initial implementation. I'm using similar code in production, and we've stopped getting errors like Loading chunk 2 failed;

Using with Redux

It's also possible to use this approach with Redux (assuming you have implemented lazy loadable reducers and sagas or whatever else you use:

const OtherReduxComponent = lazy(() =>
  promiseRetry(() => Promise.all([import('./OtherReduxComponent/reducer'),  import('./OtherReduxComponent/sagas'), import('./OtherReduxComponent')]))
    .then(([reducer, sagas, component]) => {
      injectReducer('otherReduxComponent', reducer.default);
      injectSagas('otherReduxComponent', sagas.rootSaga);
      return Promise.resolve(component);
    })
    .catch(errorLoading)
);

It would also be possible to retry each of the above promises individually instead of failing if one of them fails, but actually due to the fact that they are cached if successful, the difference won't save much bandwidth as they won't keep making network requests once they have succeeded.

Conclusion

So the above has explored how it could be possible to make react lazy loading more resilient, and improve the user experience while delivering a chunked react app. There is still a lot more that can be done – and I feel that React should probably invest some time into documenting in more detail how to do this, and perhaps provide a function that can do something like promiseRetry() above. Trying a network request a maximum of one time is always liable to fail, and by tying your app to large numbers of asynchronous calls, just to load the UI – you run the high risk of random failure. It's possible that you could have two versions of the app in the wild too during a deploy, and that case is particularly important to handle.

The best user experience could either be full reload now or: keep retrying, and it's not clear which approach is the one we should be optimizing for without measuring which situation occurs most frequently. Tuning the number of times to retry, and the delay between retries could be night and day between failing often, taking way longer than needed and finding the sweet spot. Crucially some situations exist when retrying will always fail – so there has to be a sane limit on retries.

There are also many users who hate code splitting – as it makes the browsing of sites slower (but SEO requiring the fastest possible initial page load makes this problem somewhat intractable with client side React). I have thoughts on this too, but to be honest, I'm unsure that a consensus exists on this subject – and I'll keep those to myself for now.

Ultimately for people with large web apps who wish to do code splitting – React lazy, suspense and the fallback UI possibilities have lead to a solid mechanism to split code, and control the points where the splitting occur, so that they are in sensible places. If only the provided mechanism was more robust it would be great, but in the current state (as far as I am aware) – that robustness is up to you, so thinking you can just swap to lazy() everywhere without problems is naive.

If you have thoughts, comments or feedback get in touch via info at sammorrowdrums.con I'm happy to correct anything I may have got wrong – or update the approach if there are better alternatives.