Next.js Images, Preloading, and React Suspense
Today’s topic is about those fun times when your monitoring tools scream at you about your pages being too slow to load, but you are already using Next.js with App Router and all the fancy streaming/loading features. How can that be?
Contents
- TL;DR
- The problem
- Next.js images are lazy (by default)
- Preloading and fetch priority
- React Suspense breaks preloading
- Preloading, but manually
TL;DR
Preload your important assets. If you use Suspense, be cautious with Next.js’s <Image />
and React 19’s preload directives. If a component containing <Image />
stays suspended after streaming begins, <link rel="preload">
will be appended to the end of the HTML stream and appear at the bottom, making it almost useless. In this case, you have to manually preload the image.
You can also skip the explanation and jump straight to the solution(s) if you want.
The problem
To demonstrate the problem, I have used a modified Next.js project with a single image on the page. The parts that we are interested in look like this (simplified to reduce the size):
layout.tsx
export async function Layout({ children }) {
return (
<div>
<div>Layout</div>
<Suspense fallback={<div>Loading state</div>}>
{children}
</Suspense>
</div>
);
}
page.tsx
export default async function Page() {
const something = await fetchSomething();
return <Image src="/a-very-important-image.svg" />;
}
So, nothing special, just a layout component that wraps the page component in a Suspense boundary. The page component fetches some data and renders an image.
As you can see from the ugly Devtools screenshot below, a very important image (Next.js logo from the starter project) is showing up quite late. It was queued at 1.22s before starting to load, long after the whole HTML file has finished streaming to the client.
Almost two seconds for the one and only 1.1kB SVG on the whole page! My expectation was that it would have started loading somewhere in the middle of the HTML stream (which is the blue part of the topmost localhost
line on the screenshot), but it did not.
Sorry mobile users, you’ll have to zoom in
This impacts the user experience, and the LCP1 score rightfully reflects that.
Don’t know what Lighthouse is doing, but 4.1 seconds does
not look good
Next.js images are lazy (by default)
The authors of Next.js know about the importance of images. This is why they recommend using their custom <Image />
component instead of native <img />
. Their implementation applies several build-time and runtime optimizations, such as optimizing the image for different resolutions and formats, preventing layout shifts, and providing an easy way to set up loading states.
The important part is that the default loading
behavior of <Image />
is lazy
, unlike native <img />
which has eager
as a default. It is a crucial difference that means that by default, images will not start loading until they appear in the viewport. Even if they are at the top of a page, the browser can still deprioritize them in favor of more important things.
In order to fix that, Next.js recommends setting the priority
property to true
for important images, especially the ones above the fold2.
<Image src="path/to/image.jpg" priority />
This will do two things:
- Flip the loading behavior to
eager
, so that the browser will not use lazy loading. - Enable a much more important feature: preloading.
Preloading and fetch priority
This warrants a separate article, but in short, a page can have any number of render-blocking resources that might delay your important assets (in our case, an image) from even starting to load. This is why we need to notify the browser about our precious images as early as possible.
The best place to do this is the <head>
of the document. It is the first thing the browser receives from the server, and it’s only logical that we put a preload directive there. Usually, it looks like this:
<head>
<link rel="preload" as="image" href="path/to/image.jpg" />
</head>
This will instruct the browser to start loading the image as soon as it sees this line, usually without affecting the rendering process. You can preload many different types of resources. Of course, we should not overdo it, because preloading everything can actually increase loading time by blocking more important resources from loading.
React 19 has introduced a set of helper functions for resource preloading. Those functions provide an abstraction to handle <link>
tag generation for you and can be safely used in any React 19 app, are supported by Next.js, and are actually used by <Image />
under the hood.
What Next.js <Image />
does for us looks like this (simplified, here’s the real source code):
import { preload } from "react-dom";
function Image({ src, alt, priority, fetchPriority }) {
if (priority) {
preload(src, {
as: "image",
});
}
return (
<img
src={src}
alt={alt}
loading={priority ? "eager" : "lazy"}
fetchPriority={fetchPriority}
/>
);
}
So, when you set the priority
property of <Image />
to true
, it triggers ReactDOM.preload()
which puts a preload directive in the head of the document. Prioritizing your LCP image will almost always fix the issue.
Another important native property is fetchPriority
, which gives us even more control over the resource loading order. Without it, the browser can still decide to load the image after some other resources, even with a preload directive.
To sum up, the fastest loading Next.js image component will look like:
<Image
src="path/to/image.jpg"
priority
fetchPriority="high"
/>
However, let us take a look if anything has changed in the test project:
Barely noticeable
In our case, the only thing that changed is that the image was queued before the HTML finished streaming. This is quite a marginal improvement with no real benefit. So, if setting priority
and fetchPriority
did not help you either, let us talk about Suspense.
React Suspense breaks preloading
This is the main reason why I am writing this.
Suspense is a React feature that allows the app to display a fallback component while its children are loading. It is quite likely that you have been using it to show loading states in your projects.
Less common knowledge is that Suspense is not only a helper for showing spinners, but a whole new paradigm that allows splitting the app into chunks and hydrating them separately. This architectural discussion by Dan Abramov explains it much better than me.
So what happens when we start using Suspense in our Next.js app? Next.js will replace some3 of the “suspended” parts with a fallback component and stream them to the client. Then, without closing the HTML stream, it will wait for Suspense boundaries to resolve and append the rendered components to the end of the HTML file.
Let us take a look at the returned HTML with Javascript disabled, so that we can see the elements in the order they were streamed in:
Sorry for the screenshot, but copypasting it here would
take several screens
As you can see here, the loading state appears at the top, while the page content is appended to the end of the document in a hidden div. Then, a small script is streamed in to move the div into the correct place marked by <template>
tags, removing the loading state. This enables real loading states within a single HTML stream, no need for the bundle to load. Cool stuff.
What is not expected is that <link rel="preload">
appears at the bottom of the page, too! This makes it almost useless, because it is added after the Suspense boundary is resolved. The image got queued a bit earlier, but by that time there were other resources in the queue that blocked the image from loading.
The explanation behind it can resonate with people who used Node.js/Express and tried to do different kinds of res.set()
in different places. Let us imagine a page component (a Server Component):
export default async function Page() {
// This suspends the page component
// and the Suspense boundary renders a fallback.
const something = await fetchSomething();
// This part is not reached until fetchSomething() resolves
return (
<div>
<Image src="path/to/image.jpg" priority />
{something}
</div>
);
}
Because the <Image />
component is a React element, its code will not run until it gets into the React tree. Due to that, the code that inserts a <link>
tag will run only after fetchSomething()
resolves, or possibly later. Meanwhile, Next.js will not wait for this to happen and start streaming the response with the fallback component instead.
And once the streaming begins, the <head>
element is sent to the client as soon as possible. After our suspended component resolves, it is already too late for ReactDOM.preload()
to append anything to the head, literally physically impossible because the packets have already left the server. So, it is just appended to the end of the stream. This is why preloading does not always work with Suspense.
We can always stop using Suspense for these cases, which could require removing loading states from a chunk of our app. But if we want to keep Suspense, streaming, and loading states, then we have to do some manual work.
Preloading, but manually
As I mentioned before, we are free to use the same ReactDOM.preload()
function that Next.js uses under the hood. The only thing we need to consider is that Next.js does quite a few things within their <Image />
component, including transforming the URL and the props.
Fortunately, Next.js provides us with getImageProps
to get the actual props that are passed to the <img />
tag. We just need to use it like this:
import { preload } from "react-dom";
import Image, { getImageProps } from "next/image";
export default async function Page() {
// The original props that we pass to the <Image /> component
const imageProps = {
src: "/a-very-important-image.svg",
alt: "A very important image",
priority: true,
};
// Transform them to get the native <img /> properties
const { props: transformedProps } =
getImageProps(imageProps);
// Preload using the native props, including the attributes
preload(transformedProps.src, {
as: "image",
imageSrcSet: transformedProps.srcSet,
imageSizes: transformedProps.sizes,
fetchPriority: transformedProps.fetchPriority,
});
// The preload function is already called,
// so we are free to await and suspend the component
const something = await fetchSomething();
// This part is not reached until fetchSomething() resolves
return (
<div>
<img {...transformedProps} />
{something}
</div>
);
}
Because we are preloading before doing any await
s, the Suspense boundary is not triggered and the <link>
tag safely gets into the head of the document. Important: if there are multiple nested Suspense boundaries, you might need to move the preloading logic to a higher level. Now you have faster images and better LCP.
Loads as soon as streaming starts, blazing fast
That’s it, thanks for bearing with a much longer article than I expected. If you have any questions or suggestions, feel free to reach out to me on bluesky or write me an email.
Footnotes
-
LCP stands for Largest Contentful Paint and is the most popular way to measure perceived loading performance of a web page. It is a heuristic algorithm that detects how much time it takes to load the main content of the page. Because it is a heuristic, it is not always accurate, but more often than not it is a good indicator of how fast the page loads. Google has a great article about it. ↩
-
“Above the fold” describes part of a website that is visible on initial load, before you start scrolling. It comes from the newspaper/printing industry. ↩
-
My observation is that if the suspended part of the tree resolves before the streaming starts, then it will skip the loading state as if there was no Suspense at all. Could be wrong though. ↩