TL;DR: Lazy loading means "don't load this until it's actually needed." Images load when they scroll into view instead of all at once when the page opens. Components load when the user navigates to them instead of on startup. This makes your app feel faster because the browser isn't downloading 80 images the user might never scroll to. If you're using Next.js, the <Image> component does image lazy loading for you automatically.
Why AI Coders Need to Know This
You asked your AI to build a photo gallery app. It built one. Looks great. Then you run Google Lighthouse and see a wall of red:
- Performance: 31/100
- Largest Contentful Paint: 9.2s
- "Defer offscreen images" — 14 images flagged
- "Reduce unused JavaScript" — 1.2MB of code loading on the homepage
"Defer offscreen images" is Lighthouse's way of saying: you're loading all your images immediately, including the ones that are 20 scrolls below the visible area. Nobody has scrolled there yet. The browser downloaded them anyway. That's wasted bandwidth — and it's slowing down the images that ARE visible.
This is a lazy loading problem. And there are two versions of it to understand: image lazy loading and JavaScript lazy loading. They both mean "load it later," but they work differently and you fix them differently.
The reason AI coders keep running into this: AI-generated Next.js apps often include lazy loading code — the <Image> component, React.lazy(), next/dynamic — but if you don't understand what it's doing, you can accidentally break it. You might replace <Image> with a regular <img> tag because it looked simpler. Now you've lost all the lazy loading. Or your AI adds loading="lazy" to your hero image — the first thing the user sees — which actually makes your page feel slower.
You don't need to understand how lazy loading is implemented under the hood. You need to know what it does, when it helps, and when it backfires.
Real Scenario: Photo Gallery, 9-Second Load, Lighthouse Is Not Happy
You built a portfolio site for a photographer client using Next.js. It has a gallery page with 60 photos. The homepage loads fine in development, but in production, Lighthouse gives it a performance score of 31.
The problem: all 60 images are loading the moment the page opens, even though visitors can only see 6 at a time. The browser is making 60 separate image requests before it can show anything useful. That's what a 9-second LCP looks like.
You paste the Lighthouse results into Claude Code:
Prompt I Would Type
My Next.js photo gallery has a Lighthouse performance score of 31.
It's flagging "defer offscreen images" on 54 images. The gallery page
has 60 photos displayed in a grid. Right now I'm using regular <img>
tags. Fix the image loading so images only load when they're close to
the visible area. Also make sure the layout doesn't jump around when
images load in.
Claude rewrites the gallery. Here's what the before and after looks like:
Before (regular img tag, loads everything immediately):
<!-- All 60 images download the moment the page loads -->
<img src="/photos/shot-001.jpg" alt="Mountain sunrise" />
<img src="/photos/shot-002.jpg" alt="Forest path" />
<!-- ...58 more... -->
After — option 1: plain HTML lazy loading
<!-- Only loads when the browser is about to scroll to it -->
<img
src="/photos/shot-001.jpg"
alt="Mountain sunrise"
loading="lazy"
width="800"
height="600"
/>
After — option 2: Next.js Image component (recommended for Next.js apps)
import Image from 'next/image'
// Lazy loading is the default — no extra attribute needed
<Image
src="/photos/shot-001.jpg"
alt="Mountain sunrise"
width={800}
height={600}
/>
For the component-level issue — the AI also notices the gallery is importing a heavy lightbox library that loads on every page visit, even when users don't click any photos. It converts that to a dynamic import:
import dynamic from 'next/dynamic'
// Lightbox code only downloads if/when a user opens a photo
const PhotoLightbox = dynamic(() => import('./PhotoLightbox'), {
loading: () => <div className="lightbox-loading">Loading...</div>,
ssr: false
})
Result: Lighthouse score jumps from 31 to 87. The gallery still shows all 60 photos — they just load as the user scrolls instead of all at once.
What AI Generated — Explained in Plain English
Here are the three lazy loading patterns your AI will reach for, and what each one actually does:
1. loading="lazy" — The HTML Attribute
<img src="photo.jpg" alt="Description" loading="lazy" width="800" height="600">
This is a single attribute built into HTML itself. No JavaScript, no framework, no library. You add loading="lazy" to any <img> or <iframe> tag and the browser handles the rest. It calculates when the image is about to scroll into the viewport and only then starts downloading it.
In plain English: The browser says "I'll put a placeholder here, and when the user scrolls close enough that they're about to see this image, I'll go grab it."
Notice the width and height attributes — those are not optional. Without them, the browser doesn't know how much space to reserve for the image. When the image loads, the page layout shifts down to make room for it. That's a Cumulative Layout Shift (CLS) penalty. Always set width and height on lazy-loaded images.
2. Next.js <Image> Component — Automatic Lazy Loading
import Image from 'next/image'
<Image
src="/photos/shot-001.jpg"
alt="Mountain sunrise"
width={800}
height={600}
/>
The Next.js <Image> component is a wrapper around the standard HTML <img> tag that adds a bunch of optimizations for free: lazy loading by default, automatic WebP conversion, responsive sizing, and blur-up placeholders. You don't need to add loading="lazy" — it's already there.
In plain English: Replace your <img> tags with <Image> and Next.js handles the lazy loading, image resizing, and format optimization automatically. The one trade-off: you have to provide width and height, or use the fill prop inside a positioned container.
One important exception: Your above-the-fold hero image — the big image visitors see the moment the page loads — should NOT use lazy loading. More on this in a moment.
3. React.lazy() + Suspense — Lazy Loading Components
import React, { lazy, Suspense } from 'react'
// The HeavyChart code only downloads when this component is rendered
const HeavyChart = lazy(() => import('./HeavyChart'))
function Dashboard() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart data={chartData} />
</Suspense>
)
}
React.lazy() is React's built-in way to lazy-load components. The component's JavaScript file doesn't download until React actually tries to render it. The Suspense wrapper handles what to show while the component code is downloading — you put a loading spinner or skeleton loader in the fallback.
In plain English: Instead of loading every component's code when the app starts, React.lazy says "download this component's code only when you actually need to show it." This is closely related to code splitting — React.lazy creates a split point where the component gets its own file.
Two Types of Lazy Loading (They Work Differently)
Keep these separate in your head. They both mean "load it later," but the mechanics are completely different and the problems they cause are different too.
Image Lazy Loading — The browser skips downloading image files until the user scrolls near them. Controlled by the loading="lazy" attribute or the Next.js <Image> component. This is about reducing network requests and data transfer.
JavaScript Lazy Loading (Code Splitting) — The browser skips downloading JavaScript files until the code is actually needed. Controlled by React.lazy(), next/dynamic, or dynamic import(). This is about reducing the size of the initial JavaScript bundle. Related: What Is Code Splitting?
You can have one without the other. A page with perfectly lazy-loaded images can still have a massive JavaScript bundle. A page with well-split JavaScript can still load all its images upfront. Both problems show up in Lighthouse; they just appear under different categories.
Image Lazy Loading in Practice
Think about a news website with 30 articles on the homepage, each with a thumbnail. Without lazy loading, the browser fires off 30 image requests the second the page loads — even though the visitor can only see maybe 5 articles in their browser window.
With lazy loading: the browser loads the first 5–8 images immediately (whatever fits in the viewport plus a small buffer below). As the user scrolls down, the browser quietly starts loading the next batch. The user never sees blank images. The page loads much faster. The browser does much less work upfront.
JavaScript Lazy Loading in Practice
Think about a SaaS app with a public landing page, a user dashboard, and an admin panel. Without code splitting and lazy loading, the browser downloads JavaScript for all three sections even on the landing page — including code for admin features that 99% of visitors will never see.
With JavaScript lazy loading: the landing page only loads landing page code. Dashboard code loads when the user logs in. Admin code loads only when an admin user visits the admin panel. This is how Vite and Next.js handle routing — each page or section can be its own downloaded chunk. Also see: What Is Hydration? for how Next.js handles the transition from server-rendered HTML to interactive JavaScript.
What AI Gets Wrong About Lazy Loading
AI coding tools add lazy loading reliably — but they sometimes apply it in the wrong places. Here's what to watch for:
1. Lazy Loading the Hero Image (Above the Fold)
This is the most common mistake. Your hero image — the big photo or graphic at the top of the page that visitors see immediately — should load as fast as possible. Adding loading="lazy" to it tells the browser "wait until the user almost scrolls to this before loading it." But the user is already there. You've just made your most important image slower.
Rule: Never lazy-load images that are visible when the page first loads (above the fold). Only lazy-load images that require scrolling to reach.
In Next.js, you can override the default lazy loading with priority:
<!-- This is the hero image. Load it immediately. -->
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority {/* Tells Next.js: don't lazy-load this, preload it */}
/>
2. Forgetting the Loading Placeholder
When you lazy-load a component with React.lazy(), there's a moment while the JavaScript is downloading where the component doesn't exist yet. If you don't provide a fallback in your Suspense wrapper, React will throw an error — or worse, show nothing at all, leaving the user staring at a blank area.
AI sometimes generates the lazy import correctly but leaves a minimal fallback like fallback={null}. This means the component area is just empty while loading. From the user's perspective, part of the page is gone for a second, then suddenly appears. That's jarring.
A skeleton loader — a gray animated placeholder in the shape of the content — is much better. Ask your AI to generate one if it doesn't add it automatically.
3. Lazy Loading Tiny Components That Don't Benefit
Not everything needs to be lazy-loaded. A small <Button> component, a <Badge>, a simple icon — these are tiny. Lazy loading them adds the overhead of a separate network request (which has its own latency cost) for something so small it would have loaded faster if it was just bundled with everything else.
Lazy loading makes sense for heavy components: chart libraries, rich text editors, PDF viewers, video players, map embeds, data tables with lots of dependencies. For small UI components that are always visible, keep them in the main bundle.
When Lazy Loading Causes Problems
Layout Shift (CLS) — The Page Jumps as Images Load
This is the most common lazy loading side effect. You've seen it: you're reading text on a page, an image above loads in, and everything shifts down a few hundred pixels. You lose your place. This is Cumulative Layout Shift (CLS), and Google measures it as a Core Web Vitals metric.
It happens because the browser doesn't know how tall the image is until it downloads it. If you don't tell the browser upfront (via width and height attributes), it reserves no space. When the image loads, the page layout shifts to accommodate it.
The fix: Always include width and height on any lazy-loaded image. The browser can then reserve exactly the right amount of space before the image loads, so nothing shifts when it appears.
<!-- Bad: no dimensions, will cause layout shift -->
<img src="photo.jpg" alt="Description" loading="lazy">
<!-- Good: dimensions set, space reserved before image loads -->
<img src="photo.jpg" alt="Description" loading="lazy" width="800" height="600">
Blank Areas Where Images Should Be
Sometimes lazy loading kicks in too late — the user scrolls fast, or the network is slow, and they reach an image before it's loaded. They see a blank gray box or broken image placeholder.
The browser's built-in lazy loading has a "load threshold" — it starts loading images a certain distance before they scroll into view. Most browsers use around 1200px as the threshold, so this is rarely an issue in practice. But it's more likely to happen on slow connections or when you're using a custom scroll-based lazy loading library.
The Next.js <Image> component handles this gracefully with blur-up placeholders: a tiny blurry version of the image loads first (almost instantly), and the full-resolution image swaps in when ready.
Components Flashing In (No Suspense Fallback)
When a lazy-loaded component finishes downloading and renders, it should slot smoothly into the page. Without a proper Suspense fallback that matches the component's size, the page layout can jump when the component renders — same problem as CLS for images, just in JavaScript form.
The fix is a skeleton loader that mimics the shape of the component that's loading. When the real component renders, it replaces a placeholder the same size — so nothing shifts.
What to Tell Your AI
These prompts get you exactly what you need without having to know the implementation details:
For image-heavy pages
I have a page with a lot of images below the fold. Add lazy loading
to all images except the hero image at the top. Make sure every
lazy-loaded image has width and height set to prevent layout shift.
I'm using Next.js — use the Image component where possible.
For heavy components
This component imports a large chart library. Convert it to use
React.lazy() with a Suspense boundary. Show a skeleton loader as
the fallback that matches the approximate size of the chart. Don't
lazy-load it if it's visible above the fold.
For Lighthouse "defer offscreen images" warnings
Lighthouse is flagging "Defer offscreen images" on my page. Audit
all the images on this page. Add loading="lazy" to any image that
won't be visible when the page first loads. Add the priority prop
to the first image visible in the viewport (above the fold).
Make sure all images have explicit width and height.
For layout shift problems
My lazy-loaded images are causing layout shift (CLS). The page
jumps when images load in. Fix this by making sure every image
has width and height attributes set. Also add a blur placeholder
to the Next.js Image components while they load.
Frequently Asked Questions
What is lazy loading in simple terms?
Lazy loading means a resource — an image, a video, a component, a chunk of JavaScript — doesn't load until it's actually needed. Images load when they're about to scroll into view. Components load when a user navigates to that part of the app. This makes your app feel faster because the browser isn't downloading a hundred things the user might never see.
Is lazy loading automatic in Next.js?
For images: yes, if you use the Next.js <Image> component. It lazy-loads images automatically and also handles resizing and WebP conversion. For JavaScript: Next.js automatically splits code by page, but it doesn't lazy-load components within a page. For that you use next/dynamic, which is Next.js's wrapper around React.lazy().
What does loading="lazy" do?
It's a single HTML attribute you add to an <img> or <iframe> tag. It tells the browser: don't download this image until the user scrolls close to it. Before this attribute existed, browsers downloaded every image on the page immediately, even images 10 screens below where the user was. loading="lazy" is supported in all modern browsers and requires zero JavaScript.
What is a skeleton loader?
A skeleton loader is a placeholder — usually a gray animated box in the shape of the content that's loading. You've seen them on LinkedIn, YouTube, and Facebook. They appear while a component or image is loading and then swap out for the real content. Skeleton loaders prevent jarring blank areas and make the app feel responsive even when data is still fetching. When using React.lazy() with Suspense, the fallback prop is where you put your skeleton loader.
What is Cumulative Layout Shift (CLS)?
Cumulative Layout Shift (CLS) measures how much the page layout jumps around as it loads. If an image loads without defined width and height, the browser doesn't know how much space to reserve for it — so when the image appears, everything below it shifts down. This is frustrating for users and penalizes your Lighthouse score. Fix it by always setting width and height on lazy-loaded images.
Keep Learning
- What Is Next.js? — The Next.js Image component handles lazy loading automatically
- What Is React? — React.lazy() for component-level lazy loading
- What Is Code Splitting? — How lazy loading relates to splitting your JavaScript bundle
- What Is React Suspense? — The Suspense boundary that wraps lazy-loaded components
- What Is Hydration? — How Next.js loads JavaScript after the HTML is shown
- What Is Vite? — How Vite handles code splitting and lazy loading automatically