How to lazy load images in React without hurting LCP
To lazy load an image in React, render a normal img element with loading="lazy". Keep the real
URL in src, add srcSet and sizes when responsive variants are available, and provide width
and height so the browser can reserve space.
The important exception is the image likely to become the page's Largest Contentful Paint (LCP) element. Keep that image eager and discoverable in the initial HTML. Lazy loading it delays the request that the page most needs to finish quickly.
| Image role | Loading decision | Other important markup |
|---|---|---|
| Hero or likely LCP image | Eager | fetchPriority="high" when verified |
| Other image visible in the first viewport | Eager | Dimensions and responsive sources |
| Ordinary offscreen content image | Lazy | src, srcSet, sizes, and dimensions |
| Decorative CSS background | Separate mechanism | Do not replace meaningful img content |
“Above the fold” is not a fixed image number. Viewport height, responsive layouts, banners, and localized content can move the same image into or out of the initial viewport. Classify images by the rendered page, then verify the choice on representative screen sizes.
Use React's native loading prop
React passes the loading prop to the browser's native image element. You do not need an effect,
scroll handler, or Intersection Observer library for an ordinary offscreen image.
The following local preview renders React on the server and sends complete image markup to the browser. You can reuse the components in an existing server-rendered React application. The preview needs Node.js and separately installed Corepack. Use a maintained Node.js release; this preview was tested with Node.js 24.15.0 and 26.8.1, Yarn 4.12.0, React 19.2.6, and Chrome 151. These are tested versions, not an intrinsic minimum for native image loading. The commands use a POSIX shell.
Create a new, empty react-image-preview directory and open a terminal there. Save package.json:
{
"name": "react-image-preview",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"esbuild": "0.27.0"
}
}
Save .yarnrc.yml to select local dependencies and ignore an enclosing project's Yarn executable:
nodeLinker: node-modules
enableGlobalCache: false
ignorePath: true
Create the lockfile boundary before installing, so Yarn treats this directory as its own project:
touch yarn.lock && corepack yarn install
If installation fails, fix the reported network or package-manager problem and repeat that command in the same directory. Keep your source files.
Create an images directory with these four WebP files from your own photos. Each pair must show
the same crop; the widths in srcSet must match the files' actual pixel widths. Change the alternative
text to describe your photos.
| File | Pixel dimensions | Role |
|---|---|---|
images/harbor-640.webp | 640×360 | Small hero candidate |
images/harbor-1280.webp | 1280×720 | Large hero and fallback |
images/workshop-480.webp | 480×320 | Small content candidate |
images/workshop-960.webp | 960×640 | Large content and fallback |
Save this component as ResponsiveImage.tsx:
import type { ReactNode } from 'react'
interface ResponsiveImageProps {
alt: string
height: number
sizes: string
src: string
srcSet: string
width: number
}
export function ResponsiveImage({
alt,
height,
sizes,
src,
srcSet,
width,
}: ResponsiveImageProps): ReactNode {
return (
<img
className="responsive-image"
src={src}
srcSet={srcSet}
sizes={sizes}
width={width}
height={height}
loading="lazy"
decoding="async"
alt={alt}
/>
)
}
Keep an actual image URL in src. Moving the only URL into a custom data-src attribute makes the
request depend on your JavaScript and prevents the browser's preload scanner from discovering it in
normal image markup. It also leaves the image unloaded if the script fails.
The decoding="async" prop is a separate hint about decoding. It does not decide when the network
request starts and it does not replace loading="lazy".
Save ImagePage.tsx. The large spacer puts the workshop image well outside the initial viewport
for this loading experiment; it is not a layout to copy into your application.
import type { ReactNode } from 'react'
import { HeroImage } from './HeroImage.tsx'
import { ResponsiveImage } from './ResponsiveImage.tsx'
export function ImagePage(): ReactNode {
return (
<main>
<h1>React image preview</h1>
<section className="hero-column">
<HeroImage />
<p>The harbor photo loads with the page.</p>
</section>
<section className="demo-spacer">
<p>Scroll to the workshop photo.</p>
</section>
<section className="image-column">
<ResponsiveImage
src="/images/workshop-960.webp"
srcSet="/images/workshop-480.webp 480w, /images/workshop-960.webp 960w"
sizes="(max-width: 40rem) 100vw, 40rem"
width={960}
height={640}
alt="A technician calibrating a camera rig"
/>
<p>The workshop photo keeps this caption in place while it loads.</p>
</section>
</main>
)
}
Lazy loading only changes when the request starts. It does not resize, compress, convert, cache, or describe the image.
Keep the LCP image eager
Do not apply loading="lazy" to a likely LCP image. Render its URL in the initial HTML when your
React framework supports server rendering so the browser can discover it without waiting for
client-side rendering or another data request.
Save HeroImage.tsx:
import type { ReactNode } from 'react'
export function HeroImage(): ReactNode {
return (
<img
className="responsive-image"
src="/images/harbor-1280.webp"
srcSet="/images/harbor-640.webp 640w, /images/harbor-1280.webp 1280w"
sizes="(max-width: 80rem) 100vw, 80rem"
width={1280}
height={720}
loading="eager"
fetchPriority="high"
alt="Fishing boats returning to the harbor at sunrise"
/>
)
}
fetchPriority="high" is a relative priority hint, not a guarantee about request order. Reserve it
for the measured LCP image. If several images are marked high priority, the browser has less useful
information for deciding which one matters most.
React 19 can also emit an automatic image preload during server rendering. Inspect the resulting HTML before adding a manual preload: React's image documentation describes the conditions. A preload should use the same responsive candidates and slot size as the image.
An image can be the LCP element on mobile but not desktop, or the reverse. Use field performance data to confirm which element becomes LCP for real visitors instead of assuming that every large image or carousel slide is critical.
Combine lazy loading with responsive images
A delayed request can still download a needlessly large file. With width descriptors such as
480w, srcSet supplies the candidate files and sizes describes the width of the rendered slot.
The browser combines that information with the device pixel density and chooses a candidate.
Write sizes for the layout, not for the source file. In this preview, the workshop photo fills the
viewport up to a 40rem column, and the hero fills it up to 80rem. The CSS below makes those limits
real. With the preview's 16-pixel root font, they are 640 and 1,280 CSS pixels. Padding or a narrower
container in your application needs a correspondingly smaller sizes value.
The browser runs the same responsive candidate selection for eager and lazy images. The loading
decision does not remove the need for correct candidates and an accurate sizes value.
The browser may reuse a cached larger candidate, and high-density screens may need more pixels than
these two candidates provide. Check currentSrc with a cold cache instead of treating one filename
as a universal choice. See responsive images
for the slot-size calculation.
Reserve space to avoid layout shifts
Supply width and height for every content image. Browsers use those attributes to calculate an
aspect ratio before the image has downloaded, reserving the right amount of layout space. Your CSS
can still make the image fluid. Save preview.css:
html {
font-size: 16px;
color-scheme: light dark;
}
body {
margin: 0;
}
.responsive-image {
display: block;
width: 100%;
height: auto;
}
.hero-column {
max-width: 80rem;
margin-inline: auto;
}
.image-column {
max-width: 40rem;
margin-inline: auto;
}
.demo-spacer {
min-height: 6000px;
}
The attributes must describe the image's intrinsic aspect ratio. For example, an img can use
width={960} and height={640} when every candidate has a 3:2 ratio, even if the browser selects a
480×320 file. If art-directed sources use different crops or aspect ratios, their dimensions need
to describe the selected source rather than an unrelated fallback.
Reserved image space prevents one source of Cumulative Layout Shift (CLS), not every possible shift. Captions, ads, consent controls, fonts, and error messages can still move the page.
Run the server-rendered preview
Save preview.tsx. It serves only this page and the four known files on your loopback interface.
It reads the files at startup, so a missing photo or stylesheet stops the preview before it reports
a ready URL. This static preview has no hydration or production deployment setup.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { renderToStaticMarkup } from 'react-dom/server'
import { ImagePage } from './ImagePage.tsx'
async function main(): Promise<void> {
const images = new Map<string, Buffer>()
for (const name of [
'harbor-640.webp',
'harbor-1280.webp',
'workshop-480.webp',
'workshop-960.webp',
]) {
images.set(`/images/${name}`, await readFile(new URL(`./images/${name}`, import.meta.url)))
}
const css = await readFile(new URL('./preview.css', import.meta.url), 'utf8')
const html = '<!doctype html>' + renderToStaticMarkup(
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>React image preview</title>
<style>{css}</style>
</head>
<body><ImagePage /></body>
</html>,
)
const port = Number(process.env.PORT ?? 4173)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer between 0 and 65535')
}
const server = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store')
const image = images.get(request.url ?? '')
if (image) {
response.writeHead(200, { 'Content-Type': 'image/webp' })
response.end(image)
} else if (request.url === '/') {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(html)
} else {
response.writeHead(404)
response.end('Not found')
}
})
server.on('error', (error) => {
console.error(`Could not start image preview: ${error.message}`)
process.exitCode = 1
})
process.once('SIGINT', () => { server.close() })
server.listen(port, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address !== 'string') {
console.log(`Image preview: http://127.0.0.1:${address.port}/`)
}
})
}
main().catch((error: unknown) => {
console.error(error)
process.exitCode = 1
})
Build and run from the preview directory. The empty --tsconfig-raw setting keeps an enclosing
project's TypeScript configuration from changing this preview's JSX runtime. This replaces the
generated preview.mjs; it does not change your photos. The && keeps a failed build from starting
an older generated file.
corepack yarn exec esbuild preview.tsx --bundle --platform=node --format=esm --packages=external --jsx=automatic --tsconfig-raw='{}' --outfile=preview.mjs && node preview.mjs
Open the printed URL and scroll to the workshop photo. Press Ctrl+C in the same terminal to stop the server. If startup reports a missing file, fix its name or location and repeat the build-and-run command. After an occupied-port error, choose an available port in a POSIX shell:
PORT=0 node preview.mjs
The server will print the new URL.
When Intersection Observer is appropriate
Use the browser's native loading behavior for ordinary images. Reach for Intersection Observer only
when the component needs behavior that loading="lazy" cannot express, such as starting an
animation, recording a visibility event, or applying an expensive decorative background shortly
before it reaches the viewport.
Do not conditionally mount a normal content image only after an observer fires unless that behavior
is genuinely required. Conditional mounting delays discovery, adds a JavaScript failure path, and
requires you to design a stable fallback. Meaningful images should remain img or picture
elements with useful alternative text.
React.lazy() solves a different problem: it defers loading a JavaScript component module. It does
not automatically defer image requests rendered by that component.
Generate responsive derivatives with Transloadit
Lazy loading cannot repair an oversized source image. A practical delivery pipeline generates a small, bounded set of widths, optimizes each result, exports them to durable storage, and records their URLs and dimensions for the React view.
Transloadit's /image/resize Robot can create width variants, and /image/optimize Robot can optimize supported formats. Generate the variants before rendering the page, export them to your storage, and record their actual dimensions alongside their durable URLs. A source smaller than a target width may produce a smaller result; do not label that URL with a width descriptor it does not satisfy. See the file exporting service for storage integrations, or Smart CDN for on-demand transformations.
The React application still owns presentation decisions: which image is meaningful, its alternative text, its rendered slot size, and whether it is initially critical. Transloadit handles the media processing stage rather than deciding which image becomes LCP in a particular page layout.
Measure the result
Check the production page rather than relying only on the component source:
- Inspect the initial HTML response. The hero's
srcandsrcsetshould already be present. - Record narrow and wide viewport loads with a cold cache in browser developer tools. Confirm that the hero request starts from that HTML, without waiting for application JavaScript.
- At the top of the preview, check that the distant workshop photo has not been requested. Scroll toward it and watch its request start. Native lazy loading may fetch before the image is visible.
- Inspect each image's
currentSrcand open that URL to check the file's pixel dimensions. The element'snaturalWidthcan be density-corrected. Compare the selected file with the rendered slot and device pixel ratio. For example, Chrome with a cold cache and a pixel ratio of 1 selected the 480-pixel workshop file in a 390-pixel viewport and the 960-pixel file for the 640-pixel desktop column in this preview. - Delay image responses and compare the image box and caption position before and after loading. Also block requests to check alternative text and reserved space on failure.
These checks establish discovery, selection, and layout behavior in a local page. They do not prove an LCP improvement for visitors. On your application, identify the actual LCP element and measure LCP and CLS with field data, grouped by page template and relevant viewport. web.dev's LCP guidance explains how resource discovery contributes to load delay.
Browser lazy-loading distances and scheduling are implementation-dependent. Avoid a rule such as “lazy load everything after the third image” or an assumed fixed pixel threshold. Measure the pages and devices your users actually receive.
Common React lazy-loading mistakes
- Lazy loading the hero or measured LCP image.
- Moving the only image URL from
srctodata-src. - Rendering critical images only after client-side code or a data request completes.
- Supplying
srcSetwithout an accuratesizesvalue. - Omitting
widthandheightbecause CSS eventually controls the image size. - Marking every image with
fetchPriority="high". - Using an observer library only to reproduce native browser behavior.
- Assuming
React.lazy()controls image network requests. - Deferring a 3,000-pixel source instead of generating a suitable derivative.
For the underlying browser rules, see the HTML lazy-loading standard. With scripting disabled, browsers load these native lazy images eagerly; keeping real URLs in the markup still lets the content load.
