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.
To be clear:
Request A --------> Response ARequest B --------> Response BRequest C --------> Response C
This is a stair-stepping cascade, alternatively:
Request A --------> Response ARequest B --------> Response BRequest C --------> Response C
This is much faster.
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} /><ProfileDetailsfavoritesCountPromise={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 datagetFavoritesCount(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 aboveconst 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 dataProfileDetails.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 aboveconst { 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} /><ProfileDetailsfavoritesCount={<FavoritesDisplay>{favoritesCount}</FavoritesDisplay>}friendsList={friends.map((friend) => (<Friend key={friend.id} friend={friend} />))}/><hr /><ProfilePostspostList={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 😎
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.
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).
👨💼 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.
👨💼 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
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;}}
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"><ErrorBoundaryfallback={<div className="app-error"><p>Something went wrong!</p></div>}><Suspensefallback={<img style={{ maxWidth: 400 }} src={shipFallbackSrc} />}><div className="search"><ShipSearchonSelection={(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><inputplaceholder="Filter ships..."type="search"value={search}onChange={(event) => {setSearch(event.currentTarget.value)}}/></div><ErrorBoundaryfallback={<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: stringonSelection: (shipName: string) => void}) {const shipResults = use(searchShips(search))return shipResults.ships.map((ship) => (<li key={ship.name}><button onClick={() => onSelection(ship.name)}><ShipImgsrc={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 promisevoid 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"><ShipImgsrc={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 />)
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 = srcimg.onload = () => resolve(src)img.onerror = reject})}// added the version to prevent caching to make testing easierconst version = Date.now()export function getImageUrlForShip(shipName: string,{ size }: { size: number },) {return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}`}
Quick Links
Legal Stuff
Social Media