Lazy Loading
Deferring routes, components, images and expensive modules until they are needed — and the loading state, the flash and the error path that every deferral creates.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Which parts of this page can I load later, and what do I owe the user in the gap I just created?
A person opened one screen. Everything they can see should work now; the things they cannot see yet can wait until they ask for them.
Wrap the heavy things in a dynamic import and render a spinner while they load. The initial page gets lighter and nothing else changes.
The spinner is now part of the design. A boundary that resolves quickly on a fast connection flashes a spinner for a fraction of a frame, which reads as a glitch rather than as feedback.
- The spinner is now part of the design. A boundary that resolves quickly on a fast connection flashes a spinner for a fraction of a frame, which reads as a glitch rather than as feedback.
- The boundary changes layout. Replacing a small spinner with a large component shoves the surrounding content, and a person who had started reading loses their place (Visual Stability).
- A lazy component below the fold that loads on scroll can arrive exactly as the user reaches it, or long after, depending entirely on the connection — so the same code produces two different experiences.
- Deferring a module that an interaction needs converts a synchronous handler into an asynchronous one. Every caller that assumed it could act immediately now needs a pending state it did not have.
- If the import rejects, the default is nothing: no error, no retry, a control that silently does not work (Network Failures Only the Client Can See).
- A screen-reader user gets no indication that anything is happening at all unless the boundary announces itself. Visually there is a spinner; in the accessibility tree there may be nothing (Live Regions and Announcement).
What is actually happening
In the browser, not in the framework.
- A dynamic
import()returns a promise. The bundler emitted the target as a separate chunk and the runtime turns the call into a request, a parse and an evaluation (Code Splitting). - The framework's lazy boundary wraps that promise: render a fallback while pending, render the component when resolved, render an error boundary when rejected. The mechanism underneath is the same promise in every framework.
- Native lazy loading exists for images and iframes through the
loading="lazy"attribute, where the browser — not your code — decides when to fetch based on proximity to the viewport (Responsive Images). IntersectionObserveris the general form of "when this is near the viewport", and it reports asynchronously off the main thread rather than requiring scroll handlers (Passive Listeners).- Prefetching decouples the fetch from the render: the chunk can be requested at low priority while the user is still reading, so the boundary resolves from cache when it is finally rendered (Resource Hints).
- Once a chunk has loaded, the module registry keeps it. A lazy boundary is lazy exactly once per document.
What this makes the browser do
And which of it is avoidable.
- A request, a parse and a compile for the chunk, on the main thread, at the moment the user is waiting for a response to their own action (The Real Cost of JavaScript).
- A render of the fallback, then a render of the real content — two commits, two style passes and potentially two layouts where an eager component had one.
- For lazily loaded images, layout work when the image arrives, unless width and height or an aspect ratio were declared so the box already existed.
- Intersection observation, which the browser does off the main thread and reports in batches — far cheaper than a scroll listener doing the same job.
What is worth deferring
The useful question is not "is this big" but "what fraction of sessions need this, and when". A module needed by nearly every session is not a lazy-loading candidate no matter its size; deferring it just adds a round trip to the critical path.
Note that three of the options below need no bundler involvement at all. Deferring an image or an iframe is an HTML attribute, and deferring a script that does not participate in the first render is what defer and type="module" already do (`defer`, `async` and `type="module"`).
What kind of heavy is it, and who needs it?
when Almost always. Route boundaries are where users already expect a transition (Route Loading Boundaries).
cost A loading state per route, and a chunk request on every first navigation unless you prefetch.
when The interaction is used by a minority of sessions and the dependency is substantial.
cost The interaction becomes asynchronous, so the trigger needs a pending state and an error path.
when It is genuinely below the fold on the smallest viewport you support.
cost Almost none — loading="lazy" is a platform attribute. Dimensions must still be reserved (Responsive Images).
when A PDF generator, a spreadsheet parser, a locale data set the current user may not need.
cost A wait inside an action the user already started; consider prefetching on hover or focus.
when Analytics, chat widgets, experiment frameworks.
cost Its authority over your page is unchanged, only its timing (Third-Party Scripts and the Supply Chain).
when Most sessions need it, or it is required before the page is usable at all.
cost Weight in the initial chunk, paid by every user on every first visit.
The boundary, written honestly
A complete lazy boundary is more code than the dynamic import that motivated it, and the extra code is the part that decides whether the feature is usable on a bad connection. The sketch below is framework-shaped, but every line of it corresponds to something every framework requires.
Two details are easy to skip and expensive to skip. The delay before showing the fallback is what prevents the flash on fast connections. The error branch is what prevents a dead control on flaky ones.
1const ChartEditor = lazy(() => import('./chart-editor'))2 3// Prefetch on intent: hover or keyboard focus is a strong enough4// signal to start the request before the click.5const prefetch = () => { void import('./chart-editor') }6 7function EditorPanel() {8 return (9 <ErrorBoundary fallback={<ChunkError onRetry={reloadOnce} />}>10 <Suspense fallback={<DelayedSkeleton />}>11 <ChartEditor />12 </Suspense>13 </ErrorBoundary>14 )15}16 17// The fallback is not just a spinner: it reserves the final size,18// waits before appearing, and announces itself.19function DelayedSkeleton() {20 const show = useDelayedFlag(SHOW_AFTER)21 return (22 <div className="editor-skeleton" style={{ minHeight: 'var(--editor-h)' }}>23 <p role="status">{show ? 'Loading the chart editor' : ''}</p>24 </div>25 )26}27 28// The error branch is the difference between a slow feature and a29// broken one. A chunk that 404s after a deploy is not retryable —30// it is gone — so reload once rather than looping.31function ChunkError({ onRetry }: { onRetry: () => void }) {32 return (33 <div role="alert">34 <p>The chart editor could not be loaded.</p>35 <button onClick={onRetry}>Try again</button>36 </div>37 )38}Four states, not two: idle, pending-but-not-yet-shown, pending-and-visible, failed. The middle one is what removes the flash, and it is the one almost every implementation skips.
What the boundary owes a keyboard and a screen reader
A lazy boundary is a change of content that the user did not directly cause on screen — or caused and cannot see the result of. That makes it an announcement problem, a focus problem and an error problem, in that order.
The specification below is deliberately conservative about focus. Moving focus into arriving content is right when the user asked to go there, such as a route change; it is wrong when they clicked something in the corner of the page and are still reading elsewhere.
semantics The trigger keeps its own semantics — a real button or a. The pending region carries role="status" (an implicit polite live region); the failure state carries role="alert".
| Enter / Space | Activates the trigger and begins the load. The trigger stays focused and stays operable while the load is pending. |
| Tab | Moves through the page as usual. The pending region is not focusable; nothing should trap focus while loading. |
| Tab (after failure) | Must reach the retry control, which is in the natural tab order inside the error region. |
| Escape | Where the boundary is inside a dismissible surface such as a dialog, closes it — a pending load must never make a surface impossible to leave. |
- — Do not move focus when content arrives in place; leave it on the trigger and announce the change.
- — On a route-level boundary, move focus to the new view's heading or main landmark once content is present (Focus Management).
- — Never remove the focused element while the load is pending — replacing the trigger with a spinner sends focus to the document body.
- — On failure, focus is safe to move to the error region only if the user is still waiting on that interaction.
- — Politely, when the wait becomes real: "Loading the chart editor".
- — When content arrives: the new content's own heading, or a short confirmation for an in-place replacement.
- — Assertively, on failure: what failed and what the user can do about it.
usually broken by The pattern invites a spinner with no text and no live region — visually complete, entirely silent in the accessibility tree — and an error path that renders nothing, leaving a focused control that appears to do nothing when activated.
How to build it
Most important first.
- Defer by likelihood, not by size alone. A large module that most sessions use is a poor candidate; a modest module that one session in fifty opens is a good one.
- Reserve the space before the content arrives. A skeleton with the final dimensions turns a jump into a fill (Visual Stability).
- Give the boundary an announced status, not just a spinner: a live region that says loading when the wait begins and confirms when it ends (Live Regions and Announcement).
- Suppress the flash: either delay showing the fallback slightly so fast resolutions never render it, or keep the previous content visible and mark it busy. Both are better than a one-frame spinner.
- Prefetch on intent. Hovering a link, focusing a control, or opening the menu that contains a button is a strong enough signal to start the fetch before the click (Route Loading Boundaries).
- Write the error path first. A failed chunk must produce a visible, announced, retryable error and a report to your error tracker (Frontend Error Tracking).
- Prefer the platform where it exists:
loading="lazy"on images and iframes needs no JavaScript, no observer and no boundary.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A loading boundary with no announced state is silent to assistive technology. The visual spinner conveys nothing; a
role="status"region with text is what actually reaches a screen-reader user (Live Regions and Announcement). - Announcements must be polite, not assertive. A boundary that interrupts whatever the user was listening to in order to say "loading" is worse than saying nothing.
- Focus must not be stolen when content arrives, and must not be stranded either. If focus was on the control that triggered the load, leave it there and announce; if the boundary replaces the focused element, move focus deliberately to the new content (Focus Management).
- A failed chunk must produce a real error that is announced and reachable by keyboard, with a retry control in the tab order. A dead button is the worst possible outcome for someone who cannot see that nothing happened.
- Deferring JavaScript is an accessibility win in itself on low-end devices: less main-thread work before the interface becomes operable, and assistive technology waits on that same thread (Long Tasks).
- Native
loading="lazy"images still needalttext and reserved dimensions; laziness changes when the bytes arrive, not what the accessibility tree needs.
What can go wrong
- The flash: fallback rendered and removed within a frame or two, which users read as flickering rather than as loading.
- The jump: fallback smaller than the content, so everything below moves when the content arrives.
- The silent failure: a rejected import with no catch, leaving a control that appears functional and is not.
- The double fetch: two rapid interactions triggering the same import before the first resolves, because the loader did not deduplicate.
- The stale mount: the chunk resolves after the user has navigated away, and the component mounts into a view that is no longer displayed (Cancelling a Request Nobody Is Waiting For).
- The mitigation failing: an aggressive prefetch of every link on the page, spending the user's bandwidth on chunks they will never use and competing with resources they are waiting for.
- Lazy images above the fold, which delays the one image the page was about (Images and Fonts).
- Navigation away while a chunk is in flight: the promise resolves into a view that no longer exists. Check that the target is still current before committing (Out-of-Order Responses).
- Two triggers for the same import in quick succession, which must share one in-flight promise rather than issuing two requests (Five Components, One Request).
- A prefetch and an on-demand fetch for the same chunk racing each other, which the loader should coalesce rather than duplicate.
- A chunk requested after a deploy, against a server that no longer has it (Long-Lived Clients and Version Skew).
- A lazily loaded chunk is a public URL like any other. Deferring the admin panel does not restrict it; the server restricts it (Authorization-Aware UI).
- Dynamically injected chunk requests must satisfy your Content-Security-Policy. Strict nonce-based policies and runtime-injected script tags interact badly, and the failure shows up only in production (Content Security Policy).
- Lazily loading third-party code moves the moment the third party gains your page's authority; it does not reduce that authority (Third-Party Scripts and the Supply Chain).
- A retry loop around a failing import can turn one user's bad connection into a request flood against your CDN. Bound the retries (Retries, and the Duplicate Order).
- "Lazy loading makes the app faster." It makes the first load lighter. Whether the app feels faster depends on whether the deferred work was on the path the user actually took.
- "A spinner is a loading state." A spinner is a visual. A loading state includes announcement, reserved space, an error path and a retry.
- "Lazy load everything below the fold." Below the fold on a phone is above the fold on a desktop, and a lazily loaded hero image is a self-inflicted wound.
- "
loading='lazy'is always an improvement." On images near the top of the viewport it delays exactly the content the page exists to show. - "The boundary handles errors." Only if you wrote the error branch. The default behaviour of a rejected promise is silence.
Measuring it, and what changes in the field
- The Network panel during the interaction, to see whether the chunk was already cached from a prefetch or fetched on demand.
- Interaction latency in field data for the interactions that cross a boundary — the honest measure of whether deferral moved the cost onto the user (Interaction Responsiveness).
- Layout-shift attribution in devtools and in field data, which names the element that moved when the lazy content arrived (Visual Stability).
- Chunk load error rate in your error tracker, split by connection type where you have it (Frontend Error Tracking).
- A screen reader, actually used, on the boundary. No automated tool will tell you the wait was silent (Accessibility Testing).
- On a fast connection, every boundary resolves quickly and the fallback is a flash. The design must survive both extremes with one implementation.
- On a slow connection, the fallback is on screen long enough to be read, which is when its wording, its size and its announcement start to matter.
- On a slow device, the parse and execute of the arriving chunk is itself a long task, so the boundary resolves and the page still does not respond for a moment.
- On a metered connection, prefetching is spending someone else's money. The Save-Data preference and connection information, where available, are a reason to prefetch less.
- In a long session, everything already loaded stays loaded, so the tenth boundary crossing is free and the first was not.
- Deferral moves cost from load time into interaction time. That is usually the right trade and it is always a trade.
- Prefetching removes the wait and spends bandwidth on the possibility. On a slow or metered connection it competes with what the user is actually waiting for.
- Delaying the fallback to avoid a flash means that on a genuinely slow connection there is a short period with no feedback at all.
- Every boundary is loading UI, error UI, an announcement and tests — real code, per boundary, that an eagerly loaded component did not need.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALDynamic
import(),IntersectionObserverand theloadingattribute on images and iframes are all widely supported platform features rather than framework inventions; the boundary component around them is what differs between frameworks. - FRAMEWORK-SPECIFICThe boundary primitive differs in name and in semantics: React exposes lazy components with a suspense fallback, Vue has an async component with its own delay and timeout options, Svelte and Solid expose their own await primitives. The fallback, delay and error behaviour are not equivalent across them, so a pattern copied between frameworks needs re-checking (Reactivity Models).
- NETWORK-SPECIFICHow long a fallback is visible is a function of the connection, so the same boundary is a flash on a fast link and a several-second wait on a slow one — both must be designed for, and neither can be tested by only using the fast one.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — a deferred dependency is an asynchronous boundary introduced for delivery reasons, and it leaks into the call sites of everything that used to be synchronous.