🚀 Optimizing React Suspense: Crush Waterfalls, Preload Images, and Leverage Server Caching
React Suspense opens up a powerful pattern for colocating data with the components that need it. But like any great power, it comes with risks—and one of the biggest is the dreaded waterfall.
If you’ve ever wondered why your app feels slower than expected, you might be triggering unnecessary delays in your data loading. Let’s explore how to avoid that.
If you open your browser DevTools and look at the Network tab, you’ll see a column called “Waterfall.” That name isn’t just visual—it refers to how your app’s requests are being sequenced.
A bad waterfall looks like this:
Request A --------> Response ARequest B --------> Response BRequest C --------> Response C
Each request starts after the previous one finishes.
A good (parallel) waterfall looks like this:
Request A --------> Response ARequest B --------> Response BRequest C --------> Response C
All requests are made at the same time, reducing total wait time.
React Suspense works by suspending the component as soon as a Promise is used. If your component triggers multiple use()
calls in sequence, you’ve already created a waterfall.
function ProfileDetails({ username }: { username: string }) {const favoritesCount = use(getFavoritesCount(username))const friends = use(getFriends(username))return <div>{/* ... */}</div>}
The second fetch won’t even begin until the first one resolves. Ouch.
use
Trigger all your data requests before suspending:
function ProfileDetails({ username }: { username: string }) {const favoritesCountPromise = getFavoritesCount(username)const friendsPromise = getFriends(username)const favoritesCount = use(favoritesCountPromise)const friends = use(friendsPromise)return <div>{/* ... */}</div>}
Now both network requests begin at the same time. 🚀
Sometimes waterfalls sneak in when you separate concerns between parent and child components.
function ProfilePage({ username }: { username: string }) {const userAvatar = use(getUserAvatar(username))return (<div><Avatar url={userAvatar} /><ProfileDetails username={username} /></div>)}
The ProfileDetails
component’s data won’t even begin to fetch until the ProfilePage
finishes suspending on getUserAvatar
.
function ProfilePage({ username }: { username: string }) {getFavoritesCount(username) // start request earlygetFriends(username)const userAvatar = use(getUserAvatar(username))return (<div><Avatar url={userAvatar} /><ProfileDetails username={username} /></div>)}
By calling the data functions early (which return cached Promises), we avoid the waterfall.
loadData
HelperTo make your parent code cleaner, attach a loadData
method to your component:
ProfileDetails.loadData = (username) => ({favoritesCountPromise: getFavoritesCount(username),friendsPromise: getFriends(username),})
Use it like this:
ProfileDetails.loadData(username)
This keeps your preloading strategy reusable and maintainable.
Yes, you could also pass Promises via props:
<ProfileDetailsfavoritesCountPromise={favoritesCountPromise}friendsPromise={friendsPromise}/>
But that spreads your data fetching logic all over the place. Instead, triggering fetches early—then using use()
where needed—gives you both colocated logic and optimized performance.
Another kind of waterfall happens with images.
Imagine this scenario:
<img src="ship.jpg" />
, and only then does the browser start loading the image.That’s a visual waterfall.
function ShipDetails({ name }: { name: string }) {preloadImage(`/img/${name}.jpg`)const ship = use(getShip(name))return (<><ShipImg src={`/img/${name}.jpg`} />{/* other ship data */}</>)}
Now the image download begins as soon as we know the ship’s name—not after rendering the image tag.
Suspense helps us optimize frontend performance. But don’t forget your backend.
You can avoid redundant requests and improve refresh speed by leveraging HTTP caching.
Cache-Control: max-age=3600
This instructs the browser to cache the response for an hour.
If you have API endpoints like /api/ship-details
, simply set this header on the response server-side:
res.setHeader("Cache-Control", "max-age=3600")
Just be sure the data is okay to cache and doesn’t change too frequently.
Let’s recap what you’ve learned:
Problem | Cause | Fix |
---|---|---|
Data waterfalls | Fetch inside use() calls | Trigger Promises early |
Nested waterfalls | Child fetches after parent suspends | Preload in parent |
Image loading delays | <img src> starts late | Use preloadImage() |
Lost cache on refresh | Cache lost on reload | Use Cache-Control headers |
Good news: React 19 improves automatic parallel loading. Some of the waterfall issues described here have been fixed behind the scenes.
But it’s still important to understand:
React Suspense gives you incredible power—but if you don’t understand waterfalls, you might end up building a slower app than intended.
Avoid waterfalls by:
Cache-Control
headers on your APIStay sharp, preload smart, and render fast. 🧠💥
📚 Learn more:
Quick Links
Legal Stuff
Social Media