Suspense for Images: Preloading in React 19 Without Layout Shift
AI generated
{ }
React · Performance · UX
Preloading Images with Suspense and Avoiding Layout Shift
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.

13 min read Suspense Performance CLS Images React 19

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.

11. FAQ: Suspense Image Preloading: The Essentials at a Glance

1Does the Suspense preload pattern work with server-side rendering?
Only to a limited extent, because throwing promises during render requires client-side behavior. For server-rendered pages, it is better to ship an already known image directly on first render and use Suspense only for images loaded later on the client.
2Can I share the same cache across multiple image components in the project?
Yes, a module-level map cache like the one shown is automatically shared by every component that imports the same preloadImage function. This prevents the same image URL from being loaded twice in different parts of the application.
3What happens if an image never loads because the URL is wrong?
The underlying Image instance eventually fires onerror, and the resource then throws the error instead of staying pending. A surrounding ErrorBoundary catches that error and can display a placeholder or an error message.
4Do I need a separate Suspense boundary for every image?
Not necessarily, multiple images can share a common Suspense boundary, which makes them appear together instead of loading in individually. The choice depends on whether you want coordinated or independent appearance of the images.
5Does throwing promises cause performance problems?
No, throwing a promise during render is an officially supported React mechanism for Suspense and causes no noticeable overhead. What matters is not creating the same promise again on every render, which the cache shown here takes care of.
6How does this differ from React Query's Suspense mode?
React Query offers a built-in Suspense mode for data fetching with its own caching, retry logic, and invalidation. For images, a similar but much leaner pattern can be built by hand, as shown in this article, without introducing an additional dependency.
7Is the effort worth it for a single product page with one image?
For a single above-the-fold image, the simpler solution via width/height attributes and fetchpriority is usually enough. The Suspense pattern pays off repeatedly mainly with lists, galleries, and dynamically loaded images.
8Can I use the pattern for background images instead of img elements too?
Yes, the preloadImage function itself knows nothing about an img element, it only preloads the image data. A component can use resource.read() the same way to then set a background-image via inline style instead of rendering an img tag.
9How do I handle a very large number of images in an infinitely scrolling list?
Here it is best to trigger preloadImage only once an image is about to enter the viewport, for example via an IntersectionObserver, instead of preloading every image in the list immediately. This keeps the number of simultaneous network requests under control.
10Is this pattern compatible with React 19 Server Components?
Server Components load data differently and do not need this client-side promise throwing. The pattern is meant for Client Components explicitly marked with 'use client' that interactively load images in the browser.