Good to knowReact Suspense · 04

Suspending on Images

Keep new details from appearing next to an old picture, and keep a broken picture from breaking the card.

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

In one minute

Suspense can wait for anything that’s a promise, not just data. Images are a good example: when a product page switches products, the new name and price can arrive long before the new picture, and the browser keeps showing the old picture until the new one has loaded. Turning “image loaded” into a cached promise (preloadImage(src)) and calling use on it makes the image wait like data. Two refinements: give the image its own error boundary, so a broken picture doesn’t take down the card, and its own Suspense with a key={src}, so the details don’t wait for the picture: a new boundary shows a placeholder even during a transition.

You’ll be able to: suspend on an image, contain a broken one, and use key to reset a boundary during a transition.

Plain
ProductImage.tsx
1function Details({ id }) {2  const product = use(getProduct(id)) // 200 ms3  return (<article>4    <img src={product.image} />  {/* 600 ms */}5    <h2>{product.name}</h2>6  </article>)7}
What you see
Ceramic Mug · $18.00
🖼 mug picture
  1. render phaseconst product = use(getProduct(id)) // 200 msThe transition waits for the product data only.
  2. commit phase<img src={product.image} /> {/* 600 ms */}After 200 ms the new name and price commit, with src pointing at the new picture…
  3. browser…but the browser keeps showing the old picture until the new one has loaded: about 600 ms of the lamp’s details next to the mug.

0 / 0
Switching to the Desk Lamp in a transition: its data takes 200 ms, its picture 600 ms (all three versions recorded).

The example: product pictures

Switching from the Ceramic Mug to the Desk Lamp, inside a transition. The product data takes 200 ms; the picture 600 ms. Then switching to the Wireless Headphones, whose picture is missing (404).

1. A plain <img>

function PlainImg({ src }: { src: string }) {
	return <img src={src} alt="" data-img={id(src)} onLoad={onLoad(src)} onError={onError(src)} />
}
screen: pending ⏳ · image: p1 · details: Ceramic Mug
1200 ms  → GET /products/p4
1400 ms  ← GET /products/p4
screen: image: p4 · details: Desk Lamp
2050 ms  <img> finished loading p4

The lamp’s details were committed about 600 ms before its picture finished loading. Changing an <img>’s src doesn’t clear it: the browser keeps displaying the current picture until the new one is ready, so for that time the page showed the lamp’s name next to the mug’s picture.

2. Suspend until the picture has loaded

const imageCache = new Map<string, Promise<string>>()

export function preloadImage(src: string) {
	let promise = imageCache.get(src)
	if (!promise) {
		promise = new Promise((resolve, reject) => {
			const img = new Image()
			img.onload = () => resolve(src)
			img.onerror = () => reject(new Error(`Couldn’t load ${src}`))
			img.src = src
		})
		promise = track(`GET ${src}`, promise) // (logs the download for the recordings)
		imageCache.set(src, promise)
	}
	return promise
}
function Img({ src }: { src: string }) {
	use(preloadImage(src)) // wait until the browser has the picture
	return <img src={src} alt="" data-img={id(src)} />
}
screen: pending ⏳ · image: p1 · details: Ceramic Mug
1200 ms  → GET /products/p4
1400 ms  ← GET /products/p4
1400 ms  → GET /img/p4.svg
2000 ms  ← GET /img/p4.svg
screen: image: p4 · details: Desk Lamp

Name and picture switched together. Two costs, though: the details waited for the picture, and the broken picture now took down the whole card:

screen: pending ⏳ · image: p4 · details: Desk Lamp
2300 ms  → GET /products/p2
2550 ms  ← GET /products/p2
2550 ms  → GET /img/p2.svg
3150 ms  ✗ GET /img/p2.svg
screen: error “Couldn’t load this product.”

3. A keyed image boundary

function ProductImage({ src }: { src: string }) {
	return (
		<ErrorBoundary key={src} fallback={<span data-img="unavailable">Image unavailable</span>}>
			<Suspense fallback={<span data-img="placeholder">🖼️</span>}>
				<Img src={src} />
			</Suspense>
		</ErrorBoundary>
	)
}
screen: pending ⏳ · image: p1 · details: Ceramic Mug
1200 ms  → GET /products/p4
1400 ms  ← GET /products/p4
1400 ms  → GET /img/p4.svg
screen: image: placeholder · details: Desk Lamp
2000 ms  ← GET /img/p4.svg
screen: image: p4 · details: Desk Lamp
screen: pending ⏳ · image: p4 · details: Desk Lamp
2350 ms  → GET /products/p2
2550 ms  ← GET /products/p2
2550 ms  → GET /img/p2.svg
screen: image: placeholder · details: Wireless Headphones
3150 ms  ✗ GET /img/p2.svg
screen: image: unavailable · details: Wireless Headphones

The details committed as soon as the data was there, with a placeholder in the picture’s place, and the broken picture became “Image unavailable” while the rest of the card stayed.

How it works

Common mistakes

Interview Q&A

Can Suspense wait for things other than data?

Yes, any promise read with use: code (lazy), images, fonts, anything with a “done” event. Recorded: the page waited for a picture’s load event.

Why suspend on an image at all?

Because the browser keeps the old picture on screen until a new src has loaded, so new data can appear next to an old picture. Recorded: the lamp’s details showed about 600 ms before its picture finished loading.

Why must the image promise be cached?

Each render must see the same promise, or use suspends forever, and each new Image() would start another download.

What happens when a suspended image fails to load?

The promise rejects and use throws, so the nearest error boundary takes over. Without a narrow boundary, that was the whole card (recorded); with one, just the picture.

How does key change what a transition shows?

A changed key makes React mount a new boundary. Transitions only avoid hiding content that’s already revealed, so a new boundary may show its fallback while the rest of the update commits. Recorded: details at 200 ms with a placeholder, the picture at 600 ms.

Sources

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