HomeAbout Me

React Suspense 6: Optimizations

By Daniel Nguyen
Published in React JS
June 07, 2025
4 min read
React Suspense 6: Optimizations

Optimizations

Waterfalls

React Suspense is a powerful way to colocate data requirements with the UI that requires the data. However, there is one drawback to this approach and that involves something called “waterfalls.”

If you look at the Network tab of your DevTools, you’ll find a “waterfall” column. This displays the time each request was in flight. When the request waterfalls look like a stair-stepping cascade, that leads to a slower user experience than if all the requests start at the same time.

While "waterfall" describes the visual appearance of the network requests in general (even the fast ones), it is often (confusingly) used to simply describe what I'm calling the "stair-stepping cascade" (the slow kind of waterfall). So, in the future when I say "waterfall" I'm talking about the "stair-stepping cascade" kind unless otherwise noted.

To be clear:

Request A --------> Response A
Request B --------> Response B
Request C --------> Response C

This is a stair-stepping cascade, alternatively:

Request A --------> Response A
Request B --------> Response B
Request C --------> Response C

This is much faster.

Of course, sometimes you can't avoid a little waterfalling if the data you need depends on other data to be retrieved first (if that's the case, then fix your API to not have this limitation).

Due to the design of Suspense, you can easily create waterfalls by mistake. For example:

function ProfileDetails({ username }: { username: string }) {
const favoritesCount = use(getFavoritesCount(username))
const friends = use(getFriends(username))
return <div>{/* some profile details */}</div>
}

The trouble with this is the use(getFavoritesCount(username)) will cause the ProfileDetails to suspend until the getFavoritesCount request is resolved. Only then will the getFriends request be made. This is a waterfall.

To solve this problem is pretty simple, though maybe not obvious at first. You just need to make sure to trigger both requests before use is called:

function ProfileDetails({ username }: { username: string }) {
const favoritesCountPromise = getFavoritesCount(username)
const friendsPromise = getFriends(username)
const favoritesCount = use(favoritesCountPromise)
const friends = use(friendsPromise)
return <div>{/* some profile details */}</div>
}

This way, both requests are made at the same time and the ProfileDetails component will remain suspended when both are resolved (the order of the use calls doesn’t matter in this case).

That’s simple enough (you could even make a custom Lint rule to enforce you always do this correctly), but there’s an even trickier place where this can happen.

What if you were to nest these components?

function ProfilePage({ username }: { username: string }) {
const userAvatar = use(getUserAvatar(username))
return (
<div>
<Avatar url={userAvatar} />
<ProfileDetails username={username} />
<hr />
<ProfilePosts username={username} />
</div>
)
}
function ProfileDetails({ username }: { username: string }) {
const favoritesCountPromise = getFavoritesCount(username)
const friendsPromise = getFriends(username)
const favoritesCount = use(favoritesCountPromise)
const friends = use(friendsPromise)
return <div>{/* some profile details */}</div>
}

Can you find the waterfall? It’s not as obvious as the previous example, but it’s there. The ProfilePage component will suspend until userAvatarPromise is resolved. Only then will the ProfileDetails component trigger the favoritesCountPromise and friendsPromise requests.

This is a problem because the ProfileDetails component is not even visible to the user until the ProfilePage component is resolved. This is a waterfall.

To solve this problem, you need to trigger the requests in the parent component and pass the promises down to the child components:

function ProfilePage({ username }: { username: string }) {
const userAvatarPromise = getUserAvatar(username)
const postPromise = getPosts(username)
const favoritesCountPromise = getFavoritesCount(username)
const friendsPromise = getFriends(username)
const userAvatar = use(userAvatarPromise)
return (
<div>
<Avatar url={userAvatar} />
<ProfileDetails
favoritesCountPromise={favoritesCountPromise}
friendsPromise={friendsPromise}
/>
<hr />
<ProfilePosts postPromise={postPromise} />
</div>
)
}
function ProfileDetails({
favoritesCountPromise,
friendsPromise,
}: {
favoritesCountPromise: ReturnType<typeof getFavoritesCount>
friendsPromise: ReturnType<typeof getFriends>
}) {
const favoritesCount = use(favoritesCountPromise)
const friends = use(friendsPromise)
return <div>{/* some profile details */}</div>
}

Sheesh, that’s annoying!! I thought the whole point was to be able to colocate our data requirements with the code that requires it. That’s what’s so cool about the use hook and the Suspense model!

Well, because of the promise caching we added before, you can actually get away with keeping things as they were before and simply adding a call to the cached function in the parent component instead of adding promise props everywhere:

function ProfilePage({ username }: { username: string }) {
// preload some necessary data
getFavoritesCount(username)
getFriends(username)
getPosts(username)
const userAvatar = use(getUserAvatar(username))
return (
<div>
<Avatar url={userAvatar} />
<ProfileDetails username={username} />
<hr />
<ProfilePosts username={username} />
</div>
)
}
function ProfileDetails({ username }: { username: string }) {
// these will get the cached promise that was created by the parent above
const favoritesCountPromise = getFavoritesCount(username)
const friendsPromise = getFriends(username)
const favoritesCount = use(favoritesCountPromise)
const friends = use(friendsPromise)
return <div>{/* some profile details */}</div>
}

What’s annoying about this is that you have to remember to call the function before you render the component. This is a bit of a leaky abstraction. You could make it a tiny bit better with a utility function you tack onto the ProfileDetails if you want:

function ProfilePage({ username }: { username: string }) {
// preload some necessary data
ProfileDetails.loadData(username)
ProfilePosts.loadData(username)
const userAvatar = use(getUserAvatar(username))
return (
<div>
<Avatar url={userAvatar} />
<ProfileDetails username={username} />
<hr />
<ProfilePosts username={username} />
</div>
)
}
function ProfileDetails({ username }: { username: string }) {
// these will get the cached promise that was created by the parent above
const { favoritesCountPromise, friendsPromise } =
ProfileDetails.loadData(username)
const favoritesCount = use(favoritesCountPromise)
const friends = use(friendsPromise)
return <div>{/* some profile details */}</div>
}
ProfileDetails.loadData = (username: string) => {
return {
favoritesCountPromise: getFavoritesCount(username),
friendsPromise: getFriends(username),
}
}

But then you’ll run into issues if you decide you want to lazy load ProfileDetails. You’ll have to remember to call ProfileDetails.loadData in the parent component before you render the ProfileDetails component.

Alternatively, you could restructure your components to avoid this problem using the composition pattern we learned about in the Advanced React Patterns workshop:

function ProfilePage({ username }: { username: string }) {
const userAvatarPromise = getUserAvatar(username)
const postPromise = getPosts(username)
const favoritesCountPromise = getFavoritesCount(username)
const friendsPromise = getFriends(username)
const userAvatar = use(userAvatarPromise)
const posts = use(postPromise)
const favoritesCount = use(favoritesCountPromise)
const friends = use(friendsPromise)
return (
<div>
<Avatar url={userAvatar} />
<ProfileDetails
favoritesCount={<FavoritesDisplay>{favoritesCount}</FavoritesDisplay>}
friendsList={friends.map((friend) => (
<Friend key={friend.id} friend={friend} />
))}
/>
<hr />
<ProfilePosts
postList={posts.map((post) => (
<Post key={post.id} post={post} />
))}
/>
</div>
)
}

And maybe that’s ok, but sometimes that just doesn’t feel quite right for the UI we’re building.

Really, the problem here is that we naturally follow a render-then-fetch pattern which is we don’t fetch until we render. The pattern we should be following is a fetch-as-you-render pattern which is to say you trigger all fetch requests before you render anything. You can learn more about this in Render as you fetch (with and without suspense).

Another thing you’ll want to think about in this regard is the fact that often we “code-split” our components using lazy loading with lazy (which we cover in the React Performance workshop). Combine this with colocating data fetching and you wind up in a situation where you have a waterfall because you have to first request the code, then the code runs, then that code requests the data.

Ugh, there must be a better way!!

There is 😎

Optimizations like this is where using [Remix](https://remix.run) is a huge win. It's designed to help you avoid waterfalls naturally.

And the future deeper integration of Remix with React Server Components will make this even more powerful.

Additionally, it’s data loading primitives are designed to help you avoid waterfalls without even thinking about it.

But if you’re using raw suspense as we are in this workshop, you’ll need to think about these things.

Cache headers

As often happens with optimizations, some of the best optimizations happen on the backend. Your app can be no faster than your slowest query. So finding ways to make your queries faster is a huge win.

One way you can speed up your backend is by applying caching at various layers of your tech stack. One of these layers is in HTTP and for certain kinds of data you can use cache headers to enable the client to cache the data and prevent network requests even across page refreshes.

To do this, you set the Cache-Control header on your HTTP responses. This header can have a variety of values (called “directives”) that tell the client how to cache the response. The most common directive is max-age which tells the client how long it can cache the response.

Cache-Control: max-age=3600

This tells the client to cache the response for 3600 seconds (1 hour). This means that if the client makes a request for the same resource within 1 hour of the first request, it will use the cached response instead of making a network request.

As with all caching this comes with tradeoffs. If the data changes frequently, you might not want to cache it for very long. If the data is sensitive, you might not want to cache it at all (in which case, a server-side cache might be more appropriate).

Parallel Loading

👨‍💼 Right now our ShipDetails has to wait for the ship’s data before we render the ShipImg component which will then start loading the image. However, we can start loading the image as soon as we have the ship’s name.

Please preload the image for the ship in ShipDetails so we don’t have that waterfall.

Pull up the Network tab of your DevTools to and click a ship. You’ll notice that before the image doesn’t start loading until the ship’s data is loaded and after the image starts loading as soon as you select a ship.

Server Cache

👨‍💼 As the user selects different ships, we cache the promise in our getShip utility. But if they refresh the page, the cache is lost. Our ship data doesn’t change much so we could have the user’s browser cache the ship details for a while to make the data persist across page refreshes.

Please add a Cache-Control header to

for both our API endpoints.

index.css

body {
margin: 0;
}
* {
box-sizing: border-box;
}
.app-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}
.ship-buttons {
display: flex;
justify-content: space-between;
width: 300px;
padding-bottom: 10px;
}
.ship-buttons button {
border-radius: 2px;
padding: 2px 4px;
font-size: 0.75rem;
background-color: white;
color: black;
&:not(.active) {
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5);
}
&.active {
box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5);
}
}
.app {
display: flex;
max-width: 1024px;
border: 1px solid #000;
border-start-end-radius: 0.5rem;
border-start-start-radius: 0.5rem;
border-end-start-radius: 50% 8%;
border-end-end-radius: 50% 8%;
overflow: hidden;
}
.search {
width: 150px;
max-height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
input {
width: 100%;
border: 0;
border-bottom: 1px solid #000;
padding: 8px;
line-height: 1.5;
border-top-left-radius: 0.5rem;
}
ul {
flex: 1;
list-style: none;
padding: 4px;
padding-bottom: 30px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
li {
button {
display: flex;
align-items: center;
gap: 4px;
border: none;
background-color: transparent;
&:hover {
text-decoration: underline;
}
img {
width: 20px;
height: 20px;
object-fit: contain;
border-radius: 50%;
}
}
}
}
}
.details {
flex: 1;
border-left: 1px solid #000;
height: 400px;
position: relative;
overflow: hidden;
}
.ship-info {
height: 100%;
width: 300px;
margin: auto;
overflow: auto;
background-color: #eee;
border-radius: 4px;
padding: 20px;
position: relative;
}
.ship-info.ship-loading {
opacity: 0.6;
}
.ship-info h2 {
font-weight: bold;
text-align: center;
margin-top: 0.3em;
}
.ship-info img {
width: 100%;
height: 100%;
aspect-ratio: 1;
object-fit: contain;
}
.ship-info .ship-info__img-wrapper {
margin-top: 20px;
width: 100%;
height: 200px;
}
.ship-info .ship-info__fetch-time {
position: absolute;
top: 6px;
right: 10px;
}
.app-error {
position: relative;
background-image: url('/img/broken-ship.webp');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
width: 400px;
height: 400px;
p {
position: absolute;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
padding: 6px 12px;
border-radius: 1rem;
font-size: 1.5rem;
font-weight: bold;
width: 300px;
text-align: center;
}
}

index.tsx

import { Suspense, use, useDeferredValue, useState, useTransition } from 'react'
import * as ReactDOM from 'react-dom/client'
import { ErrorBoundary } from 'react-error-boundary'
import { useSpinDelay } from 'spin-delay'
import { getImageUrlForShip, getShip, imgSrc, searchShips } from './utils.tsx'
const shipFallbackSrc = '/img/fallback-ship.png'
function App() {
const [shipName, setShipName] = useState('Dreadnought')
const [isTransitionPending, startTransition] = useTransition()
const isPending = useSpinDelay(isTransitionPending, {
delay: 300,
minDuration: 350,
})
return (
<div className="app-wrapper">
<div className="app">
<ErrorBoundary
fallback={
<div className="app-error">
<p>Something went wrong!</p>
</div>
}
>
<Suspense
fallback={<img style={{ maxWidth: 400 }} src={shipFallbackSrc} />}
>
<div className="search">
<ShipSearch
onSelection={(selection) => {
startTransition(() => setShipName(selection))
}}
/>
</div>
<div className="details" style={{ opacity: isPending ? 0.6 : 1 }}>
<ErrorBoundary fallback={<ShipError shipName={shipName} />}>
{shipName ? (
<Suspense fallback={<ShipFallback shipName={shipName} />}>
<ShipDetails shipName={shipName} />
</Suspense>
) : (
<p>Select a ship from the list to see details</p>
)}
</ErrorBoundary>
</div>
</Suspense>
</ErrorBoundary>
</div>
</div>
)
}
function ShipSearch({
onSelection,
}: {
onSelection: (shipName: string) => void
}) {
const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)
const isPending = useSpinDelay(search !== deferredSearch)
return (
<>
<div>
<input
placeholder="Filter ships..."
type="search"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value)
}}
/>
</div>
<ErrorBoundary
fallback={
<div style={{ padding: 6, color: '#CD0DD5' }}>
There was an error retrieving results
</div>
}
>
<ul style={{ opacity: isPending ? 0.6 : 1 }}>
<Suspense fallback={<SearchResultsFallback />}>
<SearchResults search={deferredSearch} onSelection={onSelection} />
</Suspense>
</ul>
</ErrorBoundary>
</>
)
}
function SearchResultsFallback() {
return Array.from({ length: 12 }).map((_, i) => (
<li key={i}>
<button>
<img src={shipFallbackSrc} alt="loading" />
... loading
</button>
</li>
))
}
function SearchResults({
search,
onSelection,
}: {
search: string
onSelection: (shipName: string) => void
}) {
const shipResults = use(searchShips(search))
return shipResults.ships.map((ship) => (
<li key={ship.name}>
<button onClick={() => onSelection(ship.name)}>
<ShipImg
src={getImageUrlForShip(ship.name, { size: 20 })}
alt={ship.name}
/>
{ship.name}
</button>
</li>
))
}
function ShipDetails({ shipName }: { shipName: string }) {
const shipImgSrc = getImageUrlForShip(shipName, { size: 200 })
// 🦉 using "void" so we don't wait for the promise, but also signal to others
// that we're intentionally not waiting for this promise
void imgSrc(shipImgSrc)
const ship = use(getShip(shipName))
return (
<div className="ship-info">
<div className="ship-info__img-wrapper">
<ShipImg src={shipImgSrc} alt={ship.name} />
</div>
<section>
<h2>
{ship.name}
<sup>
{ship.topSpeed} <small>lyh</small>
</sup>
</h2>
</section>
<section>
{ship.weapons.length ? (
<ul>
{ship.weapons.map((weapon) => (
<li key={weapon.name}>
<label>{weapon.name}</label>:{' '}
<span>
{weapon.damage} <small>({weapon.type})</small>
</span>
</li>
))}
</ul>
) : (
<p>NOTE: This ship is not equipped with any weapons.</p>
)}
</section>
<small className="ship-info__fetch-time">{ship.fetchedAt}</small>
</div>
)
}
function ShipFallback({ shipName }: { shipName: string }) {
return (
<div className="ship-info">
<div className="ship-info__img-wrapper">
<ShipImg
src={getImageUrlForShip(shipName, { size: 200 })}
alt={shipName}
/>
</div>
<section>
<h2>
{shipName}
<sup>
XX <small>lyh</small>
</sup>
</h2>
</section>
<section>
<ul>
{Array.from({ length: 3 }).map((_, i) => (
<li key={i}>
<label>loading</label>:{' '}
<span>
XX <small>(loading)</small>
</span>
</li>
))}
</ul>
</section>
</div>
)
}
function ShipError({ shipName }: { shipName: string }) {
return (
<div className="ship-info">
<div className="ship-info__img-wrapper">
<ShipImg src="/img/broken-ship.webp" alt="broken ship" />
</div>
<section>
<h2>There was an error</h2>
</section>
<section>There was an error loading "{shipName}"</section>
</div>
)
}
function ShipImg(props: React.ComponentProps<'img'>) {
return (
<ErrorBoundary fallback={<img {...props} />} key={props.src}>
<Suspense fallback={<img {...props} src={shipFallbackSrc} />}>
<Img {...props} />
</Suspense>
</ErrorBoundary>
)
}
function Img({ src = '', ...props }: React.ComponentProps<'img'>) {
src = use(imgSrc(src))
return <img src={src} {...props} />
}
const rootEl = document.createElement('div')
document.body.append(rootEl)
ReactDOM.createRoot(rootEl).render(<App />)

utils.tsx

import { type Ship, type ShipSearch } from './api.server.ts'
export type { Ship, ShipSearch }
const shipCache = new Map<string, Promise<Ship>>()
export function getShip(name: string, delay?: number) {
const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay)
shipCache.set(name, shipPromise)
return shipPromise
}
async function getShipImpl(name: string, delay?: number) {
const searchParams = new URLSearchParams({ name })
if (delay) searchParams.set('delay', String(delay))
const response = await fetch(`api/get-ship?${searchParams.toString()}`)
if (!response.ok) {
return Promise.reject(new Error(await response.text()))
}
const ship = await response.json()
return ship as Ship
}
const shipSearchCache = new Map<string, Promise<ShipSearch>>()
export function searchShips(query: string, delay?: number) {
const searchPromise =
shipSearchCache.get(query) ?? searchShipImpl(query, delay)
shipSearchCache.set(query, searchPromise)
return searchPromise
}
async function searchShipImpl(query: string, delay?: number) {
const searchParams = new URLSearchParams({ query })
if (delay) searchParams.set('delay', String(delay))
const response = await fetch(`api/search-ships?${searchParams.toString()}`)
if (!response.ok) {
return Promise.reject(new Error(await response.text()))
}
const ship = await response.json()
return ship as ShipSearch
}
const imgCache = new Map<string, Promise<string>>()
export function imgSrc(src: string) {
const imgPromise = imgCache.get(src) ?? preloadImage(src)
imgCache.set(src, imgPromise)
return imgPromise
}
function preloadImage(src: string) {
return new Promise<string>(async (resolve, reject) => {
const img = new Image()
img.src = src
img.onload = () => resolve(src)
img.onerror = reject
})
}
// added the version to prevent caching to make testing easier
const version = Date.now()
export function getImageUrlForShip(
shipName: string,
{ size }: { size: number },
) {
return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}`
}

Tags

#React

Share

Previous Article
React Suspense 5: Responsive

Table Of Contents

1
Optimizations
2
Parallel Loading
3
Server Cache

Related Posts

React Testing 8: Testing custom hook
September 09, 2025
1 min
© 2025, All Rights Reserved.
Powered By

Quick Links

About Me

Legal Stuff

Social Media