A custom promise-wrapping pattern against Cumulative Layout Shift
An image that loads after the rest of the layout has settled pushes visible content around and hurts the Cumulative Layout Shift score. React Suspense lets you deliberately hold back rendering an image component until the file is actually sitting in the browser cache. This article shows a custom preload pattern built on promise wrapping, including caching, error handling, and the limits of the approach.
Table of Contents
- 1. Why Late-Loading Images Break the Layout
- 2. The Basic Pattern: A Suspense-Ready Image Resource
- 3. Building a SuspenseImage Component
- 4. Reserving Layout Space Before the Image Arrives
- 5. Fallback Design: Placeholders Instead of Empty Space
- 6. Caching: Avoiding Duplicate Loads and Re-Suspending
- 7. Error Handling: Combining Suspense with ErrorBoundary
- 8. Preloading Multiple Images in Parallel
- 9. Limits of the Approach and Native Alternatives
- 10. Summary
- 11. FAQ
1. Why Late-Loading Images Break the Layout
A classic img tag only reserves space in the browser once its intrinsic dimensions are known, which usually means only after it has loaded. Until then the element takes up no space or the wrong amount of space in the layout, and the moment the image arrives, content below it shifts abruptly downward. This exact behavior is what the Cumulative Layout Shift score, one of the Core Web Vitals, measures, and it directly affects perceived quality and search ranking.
React Suspense offers a structural way out: instead of rendering an img element immediately and waiting for onLoad, rendering of the entire image component can be held back until the image has actually finished loading. In the meantime, the Suspense fallback occupies the reserved space, and the real image only appears once it can be displayed instantly without any visible pop-in. The prerequisite is a custom preload pattern, since native img loading is not compatible with Suspense on its own.
2. The Basic Pattern: A Suspense-Ready Image Resource
Suspense works by having a component throw a promise during render whenever the data it needs is not yet available. React catches that promise, shows the nearest fallback, and retries rendering once the promise resolves. For images this means a small wrapper function has to load a native Image object, cache its loading state, and on render either return the result or throw the pending promise.
This so-called resource encapsulates three states: pending while the image is loading, success once the image data sits in the browser cache, and error if loading fails. It is important that the same resource does not create a new promise on every render, otherwise Suspense would end up in an endless loop of fallback and re-throwing. A cache keyed by image URL reliably solves this problem.
const imageCache = new Map();
function preloadImage(src) {
if (imageCache.has(src)) {
return imageCache.get(src);
}
let status = "pending";
let result;
const promise = new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = () => {
status = "success";
result = src;
resolve(src);
};
img.onerror = (err) => {
status = "error";
result = err;
reject(err);
};
});
const resource = {
read() {
if (status === "pending") throw promise;
if (status === "error") throw result;
return result;
},
};
imageCache.set(src, resource);
return resource;
}
3. Building a SuspenseImage Component
Based on the resource, a small component can be built that simply calls resource.read() during render. As long as the image has not finished loading, that call throws the underlying promise, React catches it via the enclosing Suspense boundary and shows the fallback. Once the image is fully loaded, read() returns the URL, and the component renders a perfectly normal img element that is now guaranteed to appear instantly without visible pop-in.
This component can be used like any other image component, as long as it is wrapped in a Suspense element. The key difference from a classic img with an onLoad handler is that React pauses the rendering of the component itself, rather than just controlling a conditional display inside an already rendered component. This considerably simplifies the fallback logic, since it lives centrally at the Suspense level instead of being duplicated inside every individual image component.
import { Suspense } from "react";
function SuspenseImage({ src, alt, width, height }) {
const resource = preloadImage(src);
resource.read();
return <img src={src} alt={alt} width={width} height={height} />;
}
function ProductImage({ src, alt }) {
return (
<Suspense fallback={<ImageSkeleton />}>
<SuspenseImage src={src} alt={alt} width={640} height={480} />
</Suspense>
);
}
4. Reserving Layout Space Before the Image Arrives
Suspense alone does not prevent layout shift, it only moves the problem earlier in time: if the fallback takes up less space than the eventual image, the layout still jumps when switching from fallback to image. What matters is that the fallback and the final image reserve exactly the same dimensions, for example through fixed width and height attributes or a CSS aspect-ratio property on a wrapping container.
In practice, a container with a fixed aspect-ratio works well, where both the skeleton fallback and the later img element fill the same space via absolute positioning. This keeps the page height constant throughout the entire loading process, regardless of whether the fallback or the finished image is currently visible, and keeps the Cumulative Layout Shift score close to zero for that part of the page.
5. Fallback Design: Placeholders Instead of Empty Space
An empty gray box as a fallback is functionally correct but feels restless when many images load at once. A skeleton with a subtle pulse animation, or a low-quality image placeholder, meaning a tiny, heavily compressed preview of the image, signal to the user that something is actually loading instead of making the page feel broken. Both variants can be inserted as their own fallback component into the Suspense boundary without touching the actual loading logic.
It is important to keep the fallback deliberately small and lightweight, since it may be rendered separately for every single image in a list. An elaborately animated skeleton for a product list with fifty entries can itself become a performance drag if it creates unnecessarily many DOM nodes or expensive CSS animations. A simple CSS animation based on background-position is usually entirely sufficient.
6. Caching: Avoiding Duplicate Loads and Re-Suspending
Without a cache, every remount of the same image component, say after navigating back to a page already visited, would reload the image and suspend the component again, even though the browser has long since cached the file over HTTP. The map keyed by image URL shown in the previous section solves exactly this problem, because it holds the loading state independently of the component's React lifecycle.
For lists with many images, it also pays off to actively warm the cache, for example by calling preloadImage on hover over a link or when an item enters the viewport, well before the actual SuspenseImage component is rendered. By the time it actually renders, the promise is already resolved, and the user sees no fallback at all, just the finished image immediately.
7. Error Handling: Combining Suspense with ErrorBoundary
If loading an image fails, say because of a dead link or a network error, the resource in this pattern throws the error instead of the promise. Suspense itself only catches promises, not error objects; a classic React ErrorBoundary is additionally needed to catch the thrown error and display a fallback image or error message instead.
In practice, the ErrorBoundary is placed directly around the Suspense boundary, so a failed image never crashes the entire surrounding page, only the affected image area. For product images in a shop, a generic placeholder image that reserves the same width and height as the originally expected image makes a good fallback content for the ErrorBoundary, in order to avoid another layout jump.
class ImageErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <PlaceholderImage width={this.props.width} height={this.props.height} />;
}
return this.props.children;
}
}
function SafeProductImage({ src, alt, width, height }) {
return (
<ImageErrorBoundary width={width} height={height}>
<Suspense fallback={<ImageSkeleton width={width} height={height} />}>
<SuspenseImage src={src} alt={alt} width={width} height={height} />
</Suspense>
</ImageErrorBoundary>
);
}
8. Preloading Multiple Images in Parallel
For an image gallery or a product carousel with several images, you typically don't want each image to suspend one after another, creating a waterfall loading order. Since browser network requests can run in parallel anyway, it is enough to trigger all the needed preloadImage calls before the actual render, for instance in a parent effect or right when the image list is assembled, instead of starting them only inside each individual SuspenseImage instance.
A shared Suspense boundary around the entire gallery additionally ensures that either all images appear together or the fallback stays visible together, which creates a cleaner user experience than an uncoordinated, one-by-one appearance of images. For very large numbers of images, a single Suspense boundary per visible slice is preferable instead, so the entire carousel does not have to wait for the slowest image.
9. Limits of the Approach and Native Alternatives
The Suspense preload pattern is no substitute for fundamental image optimization: responsive image sizes via srcset, modern formats like WebP or AVIF, and a sensible loading value remain necessary regardless. Suspense specifically solves the rendering timing problem, not the file size or format of the images themselves, and should therefore be understood as a complement to these fundamentals, not a replacement for them.
For above-the-fold images that need to be visible immediately, fetchpriority="high" combined with a server-rendered placeholder is often the simpler solution, since the Suspense pattern requires client-side JavaScript. For images below the fold, especially in dynamically loaded lists, the pattern shown here plays to its full strength instead, combining layout stability and controlled loading behavior without any additional libraries.
| Approach | Prevents CLS | Effort | Best Suited For |
|---|---|---|---|
| Native img without dimensions | No | None | Not recommended |
| width/height attributes | Yes, with known dimensions | Low | Static images with fixed size |
| CSS aspect-ratio + skeleton | Yes | Medium | Responsive images in cards/lists |
| Suspense preload pattern | Yes, plus controlled rendering | Medium to high | Galleries, dynamic lists, SPA navigation |
| fetchpriority + SSR placeholder | Yes | Low to medium | Above-the-fold hero images |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
Suspense Image Preloading: The Essentials at a Glance
Core principle
A component throws a promise until the image is loaded, Suspense shows the fallback in the meantime.
Layout stability
The fallback and the final image must reserve exactly the same space, or the layout shifts anyway.
Caching
A URL-based resource map prevents duplicate loading and re-suspending on remounts.
Limits
Does not replace image optimization like srcset or modern formats, it complements them with controlled rendering timing.