In one minute
With use, it’s easy to load things one after another without meaning to: a component suspends on the product, and only when that’s done do its children render and start their own requests (the picture, the reviews). That’s a waterfall: the total time is the sum of the requests. The fix is to start everything a screen needs before (or while) waiting for any of it: in the click handler, a route loader, or at the top of the page. Starting is cheap; only use waits. Separately, the in-memory promise cache disappears on reload; an HTTP Cache-Control: max-age header lets the browser reuse a response without asking the server again.
You’ll be able to: spot a request waterfall, load a page’s data in parallel, and use HTTP caching under the promise cache.
1function ProductPage({ id }) {2 const product = use(getProduct(id)) // 300 ms3 return (<article>4 <h2>{product.name}</h2>5 <Suspense fallback="Loading picture and reviews…">6 <ProductImage src={product.image} /> {/* 600 ms */}7 <Reviews id={id} /> {/* 300 ms */}8 </Suspense>9 </article>)10}- trigger
const product = use(getProduct(id)) // 300 msProductPagerenders and suspends on the product. - render phase
<ProductImage src={product.image} /> {/* 600 ms */}Only once the product is known do its children render and ask for the picture. - render phase
<Reviews id={id} /> {/* 300 ms */}And here even the reviews waited for the picture: they didn’t start until it had loaded. - commit phaseThree steps, one after another.
1function startLoading(id) {2 getProduct(id) // start, don't wait3 getReviews(id)4 preloadImage(`/img/${id}.svg`)5}67startLoading('p4') // in a route loader or click handler8root.render(<ProductPage id="p4" />)910function ProductPage({ id }) {11 const product = use(getProduct(id)) // 300 ms12 return (<article>13 <h2>{product.name}</h2>14 <Suspense fallback="Loading picture and reviews…">15 <ProductImage src={product.image} /> {/* 600 ms */}16 <Reviews id={id} /> {/* 300 ms */}17 </Suspense>18 </article>)19}- trigger
startLoading('p4') // in a route loader or click handlerAll three requests start together, from code that runs before React renders (a route loader, a click handler). - render phase
const product = use(getProduct(id)) // 300 msThe components didn’t change: theirusecalls find the promises already in the caches. - commit phaseThe total time is the slowest request, half of the waterfall.
The example: one product page, three requests
The page needs the product (300 ms), its reviews (300 ms) and its picture (600 ms).
1. Fetch on render
function ProductPage({ id }: { id: string }) {
const product = use(getProduct(id)) // 1. wait for the product…
return (
<article>
<h2 data-details={product.name}>{product.name}</h2>
<Suspense fallback={<p data-fallback>Loading picture and reviews…</p>}>
<ProductImage src={product.image} /> {/* 2. …only then ask for the picture */}
<Reviews id={id} /> {/* …and the reviews */}
</Suspense>
</article>
)
}
function ProductImage({ src }: { src: string }) {
use(preloadImage(src))
return <img src={src} alt="" data-img={src.match(/(\w+)\.svg/)![1]} />
}
function Reviews({ id }: { id: string }) {
const reviews = use(getReviews(id))
return <p data-note>{reviews.length} review(s)</p>
}
0 ms → GET /products/p4
screen: fallback “Loading product…”
300 ms ← GET /products/p4
300 ms → GET /img/p4.svg
screen: details: Desk Lamp · fallback “Loading picture and reviews…”
900 ms ← GET /img/p4.svg
900 ms → GET /products/p4/reviews
1200 ms ← GET /products/p4/reviews
screen: details: Desk Lamp · image: p4 · 0 review(s)
Three steps: the picture started only after the product arrived, and here even the reviews started only after the picture had loaded. The page took 1,200 ms.
2. Start everything first
function startLoading(id: string) {
// start everything the page needs before rendering it (e.g. in a route loader or click handler)
getProduct(id)
getReviews(id)
preloadImage(`/img/${id}.svg`)
}
The same components, with startLoading('p4') called before rendering:
0 ms → GET /products/p4
0 ms → GET /products/p4/reviews
0 ms → GET /img/p4.svg
screen: fallback “Loading product…”
300 ms ← GET /products/p4
300 ms ← GET /products/p4/reviews
screen: details: Desk Lamp · fallback “Loading picture and reviews…”
600 ms ← GET /img/p4.svg
screen: details: Desk Lamp · image: p4 · 0 review(s)
All three requests started at 0 ms; the page was complete at 600 ms, the time of the slowest request. The components didn’t change: their use calls find the promises already in the caches.
React 19 pre-warming
When a component suspends, React 19 commits the fallback and then renders the suspended tree again in the background, so that siblings get a chance to start their requests. Two siblings in one boundary:
function SiblingsPage({ id }: { id: string }) {
return (
<Suspense fallback={<p data-fallback>Loading…</p>}>
<ProductName id={id} />
<Reviews id={id} />
</Suspense>
)
}
function ProductName({ id }: { id: string }) {
const product = use(getProduct(id))
return <h2 data-details={product.name}>{product.name}</h2>
}
0 ms → GET /products/p4
screen: fallback “Loading…”
0 ms → GET /products/p4/reviews
300 ms ← GET /products/p4
300 ms ← GET /products/p4/reviews
screen: details: Desk Lamp · 0 review(s)
The reviews request started right after the fallback, in parallel. But it can’t help when one request depends on another (the picture URL comes from the product), and in the nested case above it didn’t help either. Don’t count on it: start requests explicitly.
The HTTP cache
The promise cache lives in memory: a reload starts from scratch. The server can let the browser keep a response with a header. Two page loads of an exchange-rate endpoint, with and without Cache-Control: max-age=60:
const response = await fetch(url)
const rate = await response.json()
Without (no-store), after the reload:
cache-control: no-store
server has been hit 2 time(s) · from the browser cache: false
With max-age=60, after the reload:
cache-control: max-age=60
server has been hit 1 time(s) · from the browser cache: true
The second load came from the browser’s cache, and the server was asked only once. The two caches stack: the promise cache avoids repeat requests within a page; the HTTP cache avoids them across loads.
How it works
- Starting ≠ waiting. Calling
getProduct(id)starts the request and caches the promise. Onlyusewaits. Start early, wait late. - Render-as-you-fetch means starting requests when you know you’ll need them (on click, on route change), then rendering, instead of letting each component fetch when it renders (fetch-on-render). Frameworks’ route loaders do this.
- Dependent data stays sequential. If B needs A’s result, it waits. Reduce those dependencies (here, the picture URL can be derived from the id), or have the server return both.
Cache-Controldirectives:max-age=Nreuse for N seconds;no-cachestore but revalidate with the server each time;no-storenever store;privateonly the browser, not shared caches;stale-while-revalidateserve stale while refreshing.
Common mistakes
- Requests started inside the component that needs them, when the data is known earlier.
useon one promise before even creating the next in the same component: create both promises first, thenusethem.- Caching personal or fast-changing data with
max-age, or withpublicon shared caches.
Interview Q&A
What is a request waterfall, and why does Suspense make it easy?
Requests that run one after another although they could run together. With fetch-on-render, a child’s request only starts when its parent has finished suspending. Recorded: 1,200 ms in three steps.
How do you load in parallel?
Start all the requests before waiting on any: in an event handler or route loader, or at the top of the page. Recorded: everything started at 0 ms, done at 600 ms.
Doesn't React 19 fix waterfalls?
Partly. It pre-renders siblings of a suspended component so their requests start early (recorded in the sibling case). It can’t fix dependent requests, and it didn’t help in the nested case recorded here.
How is the HTTP cache different from caching promises?
The promise cache is per page and in memory. The HTTP cache is the browser’s, survives reloads, and is controlled by the server’s Cache-Control header. Recorded: with max-age=60, the reload was served from the browser cache.
no-cache vs no-store?
no-cache may store the response but must check with the server before reusing it; no-store never stores it.
Related
- Data Fetching with use and Promise Caching and Transitions:
useand promise caches. - Suspending on Images: the image preload used here.
- Code Splitting: prefetching code on intent.
Sources
- react.dev:
Suspense, React 19 upgrade guide: improvements to Suspense - MDN:
Cache-Control - Topic order inspired by Kent C. Dodds’ EpicReact React Suspense workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/Suspense