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.
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}- render phase
const product = use(getProduct(id)) // 200 msThe transition waits for the product data only. - commit phase
<img src={product.image} /> {/* 600 ms */}After 200 ms the new name and price commit, withsrcpointing at the new picture… - 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.
1function Img({ src }) {2 use(preloadImage(src)) // resolves on the image's load event3 return <img src={src} />4}56function Details({ id }) {7 const product = use(getProduct(id))8 return (<article>9 <Img src={product.image} />10 <h2>{product.name}</h2>11 </article>)12}- render phase
const product = use(getProduct(id))The data arrives after 200 ms… - render phase
use(preloadImage(src)) // resolves on the image's load event…then the image component suspends on a promise that resolves when the picture has loaded. The transition waits for both. - commit phaseAfter 600 ms more, data and picture switch together. But the details waited for the picture, and one broken picture now breaks the whole card (recorded: “Couldn’t load this product.”).
1function ProductImage({ src }) {2 return (3 <ErrorBoundary key={src} fallback={<span>Image unavailable</span>}>4 <Suspense fallback={<span>🖼️</span>}>5 <Img src={src} />6 </Suspense>7 </ErrorBoundary>8 )9}- render phase
<ErrorBoundary key={src} fallback={<span>Image unavailable</span>}>Withkey={src}, the image boundaries are new for every picture. A transition only protects content that’s already on screen, so a new boundary may show its fallback. - commit phase
<Suspense fallback={<span>🖼️</span>}>So the details commit after 200 ms, with a placeholder where the picture goes. - commit phase
<Img src={src} />The picture replaces the placeholder when it’s ready. For a broken picture, the narrow error boundary shows “Image unavailable” and the details stay (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
- Any cached promise can suspend.
preloadImagecreates anImage(), resolves onload, rejects onerror, and caches the promise by URL, so every render (and every component) gets the same one. - During a transition, React keeps revealed content and waits. That’s why version 2 waited for the picture: the image was inside the already-revealed details boundary.
- A new boundary can show its fallback.
key={src}makes React treat the image’sErrorBoundaryandSuspenseas brand-new components for every picture. New boundaries have nothing revealed to protect, so the transition commits the rest and shows the placeholder there. - Error boundary outside,
Suspenseinside. The error boundary catches the image’s rejection; resetting it with the key gives each picture a fresh chance. - The browser’s own image cache makes a second
<img>with the same URL instant, so the preload isn’t downloaded twice.
Common mistakes
- A new
Image()and promise on every render. Cache by URL. - One error boundary for the whole card. A failed image becomes a failed page.
- Waiting for big hero images in the critical path. Sometimes a placeholder now is better than a complete page later; decide per image.
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.
Related
- Promise Caching and Transitions: transitions keep revealed content.
- Rendering Arrays:
keyand component identity. - Error Boundaries: where to put them.
Sources
- react.dev:
Suspense(resetting boundaries on navigation),use - HTML spec: Updating the image data (the current picture stays until the new one is available)
- 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