Wait for pending: A Suspense algorithm exploration
Tags: react, javascript
Published: 2024-06-25 05:05:35

There is some [great discourse](https://github.com/facebook/react/issues/29898) 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

```tsx
<Suspense fallback={'loading'}>
  <A />
  <B />
<Suspense>
```

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'

```tsx
<Suspense fallback={'loading'}>
  <A>
    <ChildX />
    <ChildY />
  </A>
  <B />
<Suspense>
```

#### `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