DEV Community

Alex Reardon
Alex Reardon

Posted on

Wait for pending: A Suspense algorithm exploration

There is some great discourse going on about how <Suspense> timing should work in react@19.

In this post I explore what a <Suspense> algorithm would look like if all pending promises in a <Suspense> boundary were resolved before trying to re-render.

This started as a scratch pad, then a proposal, then I saw some problems with the approach... but I thought I would record and share it all the same. Maybe it might lead to some other better ideas!

TLDR

'Wait for pending' is an interesting variation of the react@18 and react@19 (alpha) <Suspense> algorithms. For flat async trees, 'wait for pending' allows for fast calling of render functions, and minimal re-renders. For trees with nested async components, child async components will have their initial render called slower than the react@18 algorithm.

Background

<Suspense> in react@18

  • render all possible components inside a <Suspense> boundary.
  • Re-render when any promise resolves
  • Continue until no more components throw a promise

Results in lots of rerenders, but allows parallelization of 'fetch in render' calls, and all components will have their render called as quickly as possible.

<Suspense> in react@19 (alpha)

  • stop rendering tree when a component throws a promise
  • wait for promise to resolve
  • re-render tree
  • Continue until no more components throw a promise

Results in minimal rerenders - but causes 'fetch in render' calls to be sequential (waterfall)

'Wait for pending' algorithm

Here is an idea for <Suspense> timing ('wait for pending'):

  • Always render siblings, even when they throw (like Suspense in react@18)
  • Don't re-render children until all currently thrown promises are resolved.

Let's see how it goes!

'Wait for pending' is similar to the current react@18 <Suspense> algorithm, except that rather than rendering all children when any thrown promise resolves, only render when all currently thrown promises resolve.

  • Allows for 'fetch in render' in siblings to trigger parallel fetches
  • Still has a great story for pre-fetching
  • Reduces the waste caused by re-rendering possibly expensive components
  • Expensive component renders along side siblings that throw will still be redundant. But, at least these redundant renders are reduced
  • 👎 Can slow down nested 'fetch in render' calls (see below)

Rough algorithm

  1. render children
  2. if no thrown promises, done - otherwise go to step 3
  3. wait for all thrown promises to resolve
  4. go to step 1.

Example 1: Only siblings

<Suspense fallback={'loading'}>
  <A />
  <B />
<Suspense>
Enter fullscreen mode Exit fullscreen mode

In this example, both A and B have a fetch for data in their render

react@18

Render 1

  • A renders, but throws a promise
  • B renders, but throws a promise

Render 2

  • promise from A resolves
  • A renders
  • B renders, but throws a promise

Render 3

  • promise from B resolves
  • A renders
  • B renders

react@19 alpha timing

Render 1

  • A renders, but throws a promise

Render 2

  • promise from A resolves
  • render A
  • render B, but B throws a promise

Render 3

  • promise from B resolves
  • render A
  • render B

😢 causes waterfalls if you fetch (throw) in renders
😊 avoids excessive re-rendering potentially expensive components

Wait for pending

Render 1

  • A renders, but throws a promise
  • B renders, but throws a promise
  • wait for A and B to resolve

Render 2

  • A renders
  • B renders

✅ In this case, the proposed algorithm yields great results!

Example 2: With children

Here is where 'wait for pending' strains.

Now A renders children ChildX and ChildY which also do a 'fetch in render'

<Suspense fallback={'loading'}>
  <A>
    <ChildX />
    <ChildY />
  </A>
  <B />
<Suspense>
Enter fullscreen mode Exit fullscreen mode

react@18 algorithm

Render 1

  • A renders, but throws a promise
  • B renders, but throws a promise

Render 2

  • promise thrown by A resolves
  • A renders
  • ChildX renders, but throws a promise
  • ChildY renders, but throws a promise
  • B renders, but throws a promise

Render 3

  • promise thrown by ChildX resolves
  • A renders
  • ChildX renders
  • ChildY renders, but throws a promise
  • B renders, but throws a promise

Render 4

  • promise thrown from ChildY resolves
  • A renders
  • ChildX renders
  • ChildY renders
  • B renders, but throws a promise

Render 5

  • promise thrown from B resolves
  • A renders
  • ChildX renders
  • ChildY renders
  • B renders

✅ ChildX and ChildY get rendered as early as possible
😢 Lots of redundant re-rendering

react@19 alpha algorithm

Render 1

  • A renders, but throws a promise

Render 2

  • promise from A resolves
  • render A
  • render ChildX, but B throws a promise

Render 3

  • promise from ChildX resolves
  • render A
  • render ChildX
  • render ChildY, but ChildY throws a promise

Render 4

  • promise from ChildY resolves
  • render A
  • render ChildX
  • render ChildY
  • render B, but B throws a promise

Render 5

  • promise from B resolves
  • render A
  • render ChildX
  • render ChildY
  • render B

Proposed algorithm

Render 1

  • A renders, but throws a promise
  • B renders, but throws a promise
  • wait for A and B to resolve

Render 2

  • promise thrown by A and B resolve
  • A renders
  • ChildX renders, but throws a promise
  • ChildY renders, but throws a promise
  • B renders
  • wait for ChildX and ChildY to resolve

Render 3

  • promise thrown by ChildX and ChildY resolve
  • A renders
  • ChildX renders
  • ChildY renders
  • B renders

✅ A lot less redundant rendering than the react@18 algorithm
👎 ChildX and ChildY need to wait for B to resolve before kicking off their 'fetch in render' calls. They had to wait for the slowest sibling of their parent to resolve before they could kick off their promises.
🤔 More parellisation than the react@19 algorithm, but slower to kick off initial renders for all components than react@18.

Closing thoughts

'Wait for pending' is an interesting approach.

For flat async trees, 'wait for pending' allows for fast calling of render functions, and minimal re-renders. However, when there trees with nested async components, async child components have to wait for their parents siblings to finish rendering before their initial render function is called. If the nested component was doing an expensive operation (such as a network call), then triggering the initial renders as quickly as possible is ideal (the react@18 algorithm). The 'wait for pending' is similar to the react@19 approach - except that each level can be parellised.

It was interesting to think about what a different <Suspense> algorithm could look like! Thanks for making it this far 😅.

Cheers

Top comments (0)