Topic 24.4
What Happens on Load: Lazy Loading and Perceived Speed
In one line
A fast-feeling page loads what the user sees first and delays everything else: images below the fold load when they come near, placeholders and reserved space stop the page from jumping, skeletons show the shape of what's coming, rarely used components load on demand, likely next steps are prefetched, and non-critical work waits until the browser is idle.
Think of it like this
A restaurant kitchen at opening time. The chef doesn't cook every dish on the menu before the first guest sits down. Bread and water arrive at once (what you see first), your main course is cooked when you order it (lazy), and if the waiter sees you eyeing the dessert menu, the kitchen starts warming the oven (prefetch). The table is already set with the right number of plates, so nothing gets shuffled around when the food arrives (no layout shift).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Above the fold
- The part of the page visible without scrolling. It should load first.
- LCP
- Largest Contentful Paint: the time when the biggest visible element is drawn.
- CLS
- Cumulative Layout Shift: a score for how much visible content jumps around while the page loads.
- INP
- Interaction to Next Paint: how long the page takes to visibly respond after a click or key press.
- Lazy loading
- Delaying a download (an image, a component's code) until it's actually needed.
- Blur-up placeholder
- A tiny blurred preview shown in an image's space until the real image has loaded.
- Skeleton
- A grey outline of the content that's coming, shown while it loads.
- Chunk
- A separate JavaScript file the bundler creates for code loaded with
import(). - Prefetch
- Downloading something before it's needed because the user will probably need it soon.
Step by step
01Sort everything into now, soon and later
Now: the HTML, critical CSS, the hero image and the code for what's above the fold. Soon: images just below the fold, the likely next route. Later: comments, analytics, chat widgets, dialogs the user may never open.
02Reserve space for everything that arrives late
Give images width/height or aspect-ratio, and late blocks a fixed slot. Fill the slot with a placeholder or skeleton of the same size.
// The LCP image: never lazy, high priority, dimensions known.
<img src="/hero.jpg" width={1200} height={600} alt="Team at work" fetchPriority="high" />
// A below-the-fold image: native lazy loading, ratio reserved.
<img src="/office.jpg" width={800} height={450} alt="Our office" loading="lazy" decoding="async" />03Load images when they come near
loading="lazy" covers most cases. With an observer you choose the distance (rootMargin), the scroll root, and what happens on load (fade in over a blurred preview).
04Split rarely used components and prefetch on intent
Wrap the import in a function that runs at most once. Pass it to lazy, and also call it from onPointerEnter and onFocus.
05Push non-critical work to idle time
Start analytics and widgets after the first paint, with requestIdleCallback and a timeout as a safety net.
useEffect(() => {
const id = requestIdleCallback(() => import("./analytics").then((m) => m.init()), { timeout: 3000 });
return () => cancelIdleCallback(id);
}, []);Try it yourself
- 1
Make the observer greedier
In the gallery, change
rootMarginto"400px 0px". Predict both counts before you run. A bigger margin means fewer blank frames for fast scrollers but more downloads that may never be seen. - 2
Remove the prefetch
Delete
prefetch={loadComments}from the comments section. Predict the third log line: comments now behave like reviews. - 3
Reserve the wrong height
In the layout-shift example, change
.slot.reservedtoheight: 40px. Predict how far the right-hand text moves. Reserving the wrong size still causes a (smaller) shift.
Code & diagrams
Each frame reserves its 16:9 space from the start and shows a colour preview. The real image is requested only when its frame comes within 100px of the scroll box's visible area, then fades in. Scroll the gallery and watch the images appear.
Preview
Console
Nothing logged yet.Expected console output
Images requested at the top: 3 of 12
After scrolling to 600px: 7 of 12The banner arrives 300ms after render. On the left it pushes the paragraph down by 64px (the 60px banner plus margins that stop collapsing). On the right, a skeleton of the same height holds the space, so nothing moves.
Preview
Console
Nothing logged yet.Expected console output
No space reserved: text moved 64px
Space reserved: text moved 0pxHovering Show comments starts its download, so by the time it's clicked the chunk is ready and the content appears as soon as React reveals it. Reviews starts downloading only on click, so its skeleton stays until the chunk arrives. Real code passes `() => import("./Comments")` instead of the fake chunk.
Preview
Console
Nothing logged yet.Expected console output
Comments chunk requested
Reviews chunk requested
450ms after the clicks: comments show content, reviews show skeleton
Later: reviews show contentimport { lazy } from "react";
import { preload, preinit } from "react-dom";
// 1. Prefetch a lazy component on intent.
const loadEditor = () => import("./Editor");
const Editor = lazy(loadEditor);
<button onPointerEnter={loadEditor} onFocus={loadEditor} onClick={openEditor}>Edit</button>;
// 2. React 19: hint resources from any component; React puts the <link> tags in <head>.
function ProductPage() {
preload("/fonts/inter.woff2", { as: "font", type: "font/woff2", crossOrigin: "anonymous" });
preinit("/widgets/chat.js", { as: "script" }); // download and run
return <h1>Product</h1>;
}
// 3. Next.js: <Link> prefetches routes when they scroll into view (production only).
// 4. React Router framework mode: <Link to="/cart" prefetch="intent">Cart</Link>Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Lazy-loading the hero image
Add loading="lazy" to the large image at the top of the page.
Break #2
Defining the lazy component inside another component
Move const Comments = lazy(loadComments); inside Section.
Myth vs fact
Myth
A spinner makes a page feel faster.
Fact
Skeletons that match the final layout feel faster and prevent shifts. For loads under about 100–200 ms, showing nothing is better than a flashing loader.
Myth
loading="lazy" should go on every image.
Fact
Only below-the-fold images. Lazy-loading the hero image delays LCP.
Myth
Code splitting always makes things faster.
Fact
It makes the first load smaller but adds a wait when the split part is needed. Without prefetching, too many small chunks just move the delay to the user's click.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
CLS counts only shifts not caused by recent user input (within 500 ms of a tap or key press), so content that expands because the user clicked "Show more" doesn't count, but a banner that appears by itself does.
- ▸
content-visibility: autowithcontain-intrinsic-size(Topic 16.9) is the CSS version of lazy rendering for whole sections: the browser skips rendering off-screen blocks while reserving their estimated size. - ▸
React's Suspense throttles reveals: once a fallback is showing, React waits a short time (about 300 ms in React 19) before swapping in content, so several nearby boundaries reveal together instead of popping in one by one. That is why a prefetched lazy component may still show its skeleton for a moment.
- ▸
Images in
<img srcset sizes>let the browser pick the smallest file that looks sharp for the screen; frameworks (Next.js<Image>) generate sizes, setwidth/height, lazy-load and provide blur placeholders by default.
Remember this
- 1
Perceived speed vs real speed. Users judge speed by what they see: how soon something useful appears and whether the page stays still. Google measures this with Core Web Vitals: LCP (Largest Contentful Paint: when the biggest visible element, often a hero image, is drawn; aim for under 2.5 s), CLS (Cumulative Layout Shift: how much visible content jumps around; aim for under 0.1) and INP (Interaction to Next Paint: how quickly the page responds to clicks and keys; aim for under 200 ms). The fold is the bottom edge of the first screen: content above the fold is critical, content below it can wait.
- 2
Lazy images. The simplest tool is the HTML attribute
loading="lazy"on<img>(and<iframe>): the browser delays the download until the image is near the viewport. Never lazy-load the LCP image at the top of the page; give itfetchPriority="high"instead. For more control (custom distances, a scroll box, fade-in effects, background images) use anIntersectionObserverwith arootMargin, set the realsrconly when the element comes near, then disconnect the observer. - 3
Reserve the space: no layout shift. An image without known dimensions has zero height until it loads, then pushes everything below it down: that jump is layout shift, and it makes people click the wrong thing. Always give images
widthandheightattributes (the browser computes the ratio from them) or a CSSaspect-ratio, and give late-arriving blocks (ads, banners, embeds) a fixed-height slot. A blur-up placeholder fills that reserved space with a tiny, blurred version of the image (a few hundred bytes, often inlined as a data URI) or its dominant colour, then fades the real image in on itsloadevent. - 4
Skeletons vs spinners. A spinner says "wait" and nothing more. A skeleton is a grey outline of the content that's coming (lines for text, boxes for images), drawn at the content's real size, so it also reserves space and the page feels like it is filling in. Use skeletons for content areas with a known shape, spinners (or a disabled button with "Saving…") for short actions. Don't show either for very fast loads: flashing a loader for 50 ms looks worse than nothing (React's Suspense waits a moment before revealing content for this reason). Mark loading regions with
aria-busy="true", and respectprefers-reduced-motionfor shimmer animations. - 5
Lazy components and prefetching.
lazy(() => import("./Comments"))plus<Suspense>(Topic 16.8) moves a component's code into a separate chunk that downloads only when it first renders: ideal for below-the-fold sections, dialogs, editors and charts. The cost is a wait at the moment the user asks. Prefetching hides it: start the download when the user shows intent (hovering or focusing the button or link) or when the link becomes visible. Call the sameimport()function early; the browser and the bundler cache the module, so the later render gets it immediately. Frameworks do this for routes: Next.js's<Link>prefetches routes in view in production, and React Router's framework mode has<Link prefetch="intent">. React 19'spreload()andpreinit()fromreact-domhint resources (fonts, images, scripts) early. - 6
Defer non-critical work. Analytics, chat widgets, A/B testing scripts and "recently viewed" lists don't need to run before the user can read the page. Load third-party scripts with
async/defer(or Next.js's<Script strategy="lazyOnload">), start non-critical work after the first paint in an effect, and for low-priority jobs userequestIdleCallback(runs when the browser has nothing else to do; pass atimeoutso it runs eventually). In React, wrap expensive non-urgent state updates instartTransition(Topic 16.7) so typing and clicking stay responsive.
Explain it without notes
What are LCP, CLS and INP, and which loading techniques improve each?
How would you implement lazy-loaded images, and when would you not use loading="lazy"?
Skeletons or spinners: when do you use each?
How do you make a code-split component feel instant?
Practice
Write a useInView(ref, rootMargin) hook that returns true once the element has come near the viewport, and use it to lazy-render a heavy map component.
Add width, height and lazy loading to a list of product images so the page doesn't shift.
Trade-offs
- ↔
Lazy loading saves bandwidth and speeds up the first screen, but each lazily loaded thing can make the user wait later; prefetching trades some extra downloads for that wait.
- ↔
Skeletons need a component per content shape and can look wrong if the content differs; spinners are generic but say nothing and don't reserve space.
Done when you can
I can lazy-load images with loading="lazy" or an IntersectionObserver, and I never lazy-load the hero image.
I can stop layout shift with dimensions, aspect-ratio and reserved slots.
I can choose between a skeleton and a spinner.
I can split a component with lazy and prefetch it on hover or focus.
I can defer analytics and other non-critical work until after the first paint.