React Router 6 Deferred Fetch

cover image

Deferred Data

Sometimes you want to retrieve some optional data without blocking the rest of the page that contains the critical data of your application. Examples of this optional data are comments on a blog post that render after the main content, recommended products on a shopping cart, recent searches, etc.

React Router 6 introduced the “deferred” API that allows you to “await” for critical data and “defer” optional data when calling your loaders.

The best part is that you can switch between one mode or the other by just adding or removing the await keyword from the promise that resolves the data. Ryan Florence gave an excellent explanation of this mechanism in his talk “When to Fetch” (seriously, it is an amazing talk, If you haven’t watched, bookmark it and watch it after you have finished reading this post!)

I took a look at the deferred demo app from the React Router examples folder and the documentation to discover all its potential, however, I couldn’t find an example with fetch so I decided to give it a go and play around with it, here is what I found.

Using Defer with Fetch

I created a mock server with MSW to simulate a fetch delay as well as display the same text from the original defer example.

Here is my first naive attempt:

// Don't copy paste! bad code! keep on reading...
export const loader = async () => {

    return defer({
      critical1: await fetch('/test?text=critical1&delay=250').then(res => res.json()),
      critical2: await fetch('/test?text=critical2&delay=500').then(res => res.json()),
      lazyResolved: fetch('/test?text=lazyResolved&delay=0').then(res => res.json()),
      lazy1: fetch('/test?text=lazy1&delay=1000').then(res => res.json()),
      lazy2: fetch('/test?text=lazy2&delay=1500').then(res => res.json()),
      lazy3: fetch('/test?text=lazy3&delay=2000').then(res => res.json()),
      lazyError: fetch('/test?text=lazy3&delay=2500').then(res => { 
        throw Error('Oh noo!')

So what are we doing here?

First, returning a “naked fetch” from a normal loader works because that’s what loaders expect and React Router will unwrap the response for you, however, defer accepts values or promises that resolve to values, so to get the values we need to unwrap the fetch response manually.

Fetch is great! however, you have to do some additional work, including throwing errors and unwrapping the promise as we have done above. Use the platform! 😅

Here is the result:

Defer 1

It all looked great until I opened the network tab and something didn’t look quite right 🤔

Kapture 2022-11-03 at 21 39 00

The first two requests for critical data use the await keyword are happening in a waterfall, not in parallel like the rest! in fact, if you add await to all the calls in the defer object they all happen in a waterfall, what gives!

— Did Ryan lie to us?

— is this a bug? 🐛

— Does critical data need to happen in a waterfall? 🚿

Nope!, of course not! it turns out that I forgot how Javascript works™️

Image description

The waterfall occurs because every time you create a separate await, it will pause execution before continuing to the next line and before firing the next fetch.

What we want to do to avoid the waterfall is fire all those fetch requests at the same time and only await for the response not the actual fetch.

“The earlier you initiate a fetch, the better, because the sooner it starts, the sooner it can finish”


To achieve this we can declare and fire all the fetch requests and then add the await for critical data in the defer object later.

// You can copy this one if you want
export const loader = async () => {

// fire them all at once  
  const critical1Promise = fetch('/test?text=critical1&delay=250').then(res => res.json());
  const critical2Promise = fetch('/test?text=critical2&delay=500').then(res => res.json());
  const lazyResolvedPromise = fetch('/test?text=lazyResolved&delay=100').then(res => res.json());
  const lazy1Promise = fetch('/test?text=lazy1&delay=500').then(res => res.json());
  const lazy2Promise = fetch('/test?text=lazy2&delay=1500').then(res => res.json());
  const lazy3Promise = fetch('/test?text=lazy3&delay=2500').then(res => res.json());
  const lazyErrorPromise = fetch('/test?text=lazy3&delay=3000').then(res => { 
        throw Error('Oh noo!')

// await for the response
  return defer({
      critical1: await critical1Promise,
      critical2: await critical2Promise,
      lazyResolved: lazyResolvedPromise,
      lazy1: lazy1Promise,
      lazy2: lazy2Promise,
      lazy3: lazy3Promise,
      lazyError: lazyErrorPromise

You can also do Promise.all() but with the above example, it is easier to understand what’s going on.

Here is what the network tab looks like now, beautiful parallel green bars.

parallel fetch

Now that we fixed the waterfall, let’s play around with the delay and explore a couple of interesting features of defer

Critical Data

The critical data uses the ‘await’ keyword so the loader and React Router until the data is ready before the first render (no loading spinners 🎉).

What happens if critical data (using await) returns an error? 🤔 well, the loader will throw and bubble up to the nearest error boundary and destroy the entire page or that entire route segment.

Kapture 2022-11-03 at 22 35 24

If you want to fail gracefully and don’t want to destroy the entire page then remove await which is basically telling React Router, hey! I don’t care if this data fails, it is not that important (critical) so display a localised error boundary instead. That’s exactly what the lazyError is doing in our first example.

Lazy Resolved

We are not using an await on the lazyResolved field, however, we don’t see a loading spinner at all. How is that? This is an amazing feature of defer, if your optional data is fast (faster than your critical data) then you won’t see a spinner at all because your promise will be resolved by the time the critical data finishes and the first render occurs:

The slowest critical data delay is 500ms but the lazyResolved data takes only 100ms so by the time critical2 is resolved, the lazyResolved promise has already been resolved and the data is immediately available.

Kapture 2022-11-03 at 22 41 40

The best thing about defer is that you don’t have to choose when to fetch your data, it will display optional data immediately if it is fast or shows a loading spinner if it is slow.

You can play around changing the delays and increasing/reducing the time critical to see if the spinners are shown or not.


Defer is a great API, it took me a while to understand it and make it work with fetch but it is an amazing tool to improve performance, reliability of your pages and developer experience.

The source code for the examples is available here:

Recent Posts

Is React going anywhere?

Earlier this year I had an interesting conversation with a CTO of a price comparison website (e-commerce) and he mentioned that they are moving away from React. “Wait, what?”… was my Reaction (pun 👊 intended)… please tell me more! “Yeah, it is not working for us, we are moving away for performance reasons, e-commerce is[…]

React Router 6.4 Code-Splitting

Single Page Applications that are unable to migrate can benefit from all of the goodies 🎁 that Remix provides.

What are Micro-Frontends? Really…

Time and time and again I find a lot of confusion about what Micro-Frontends really are and what they are meant to solve. Here is my take on what Micro-Frontends are by discovering what they are not.