Must knowReact Suspense · 06

Waterfalls and Caching

Requests that wait for each other, starting them together, and the HTTP cache that survives a reload.

On this page · 8 sections
  1. In one minute
  2. The example: one product page, three requests
  3. The HTTP cache
  4. How it works
  5. Common mistakes
  6. Interview Q&A
  7. Related
  8. Sources

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.

Fetch on render
ProductPage.tsx
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}
What you see
(loading)
Console (recorded)
0 ms → GET /products/p4
300 ms → GET /img/p4.svg
900 ms → GET /products/p4/reviews
1200 ms page complete
  1. triggerconst product = use(getProduct(id)) // 300 msProductPage renders and suspends on the product.
  2. render phase<ProductImage src={product.image} /> {/* 600 ms */}Only once the product is known do its children render and ask for the picture.
  3. render phase<Reviews id={id} /> {/* 300 ms */}And here even the reviews waited for the picture: they didn’t start until it had loaded.
  4. commit phaseThree steps, one after another.

0 / 0
A product page needs the product (300 ms), its picture (600 ms) and its reviews (300 ms). Times since the page started (both versions recorded).

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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react/Suspense