Lazy Loading Remotes Below the Fold #
A recommendations widget the user has not scrolled to should not be parsed before the page responds to a click — this page defers off-screen remotes behind an IntersectionObserver without introducing layout shift.
Deferring is easy; deferring well is not. Done carelessly it trades a slow page for a janky one, where content jumps as each remote arrives. The steps below cover the reservation of layout, the loading trigger, the fallback for users who never scroll, and the accessibility details that are usually skipped. It applies the allocation rules from performance budgets for federated apps.
Prerequisites #
- A host composing remotes through a
loadRemote()helper, as in dynamically loading remote modules at runtime. - React 18+ (the examples use
lazy/Suspense), or any framework with an async component primitive. - Each deferred remote able to declare a minimum rendered height — this is a contract, not a guess.
- An error boundary per remote, so a deferred load that fails degrades locally.
Step 1 — Classify every remote on the route #
Before writing any code, split the route’s remotes into three groups, because they need three different treatments.
Above-the-fold remotes render in the initial viewport and must be loaded eagerly; deferring them replaces a slow page with an empty one. Below-the-fold remotes render off-screen on load and are the subject of this page. Interaction-gated remotes render only after a click — a modal, a drawer, a tab panel — and should be loaded on the interaction, not on visibility.
The classification is per route, not per remote. A recommendations widget is below the fold on a product page and above it on the homepage.
Step 2 — Reserve the layout before you defer anything #
This step comes first because skipping it is what makes lazy loading feel worse than not bothering. A deferred remote that mounts into a zero-height container shifts everything below it, and Cumulative Layout Shift is a Core Web Vital you were probably trying to protect.
/* The host reserves space using the height the remote committed to. */
.remote-slot {
min-height: var(--slot-height, 320px);
contain: layout paint;
}
@media (max-width: 640px) {
.remote-slot { min-height: var(--slot-height-sm, 480px); }
}
The contain: layout paint declaration is worth adding: it tells the browser that nothing inside the slot can affect layout outside it, which limits the cost of the eventual mount to the slot itself.
The height has to come from the remote team, recorded in the same manifest that holds its entry URL. A number the host guessed will be wrong the first time the remote’s design changes, and nobody will notice until the layout shift metric moves.
{
"recommendations": {
"entry": "https://cdn.example.com/recommend/remoteEntry.a91f3c.js",
"module": "./Widget",
"slotHeight": { "default": 320, "small": 480 }
}
}
Step 3 — Load on visibility, with margin #
IntersectionObserver with a generous rootMargin starts the load before the slot is actually visible, so the content is usually there by the time the user arrives.
// src/DeferredRemote.jsx
import { useEffect, useRef, useState } from 'react';
export function DeferredRemote({ name, height, children }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || visible) return;
// No IntersectionObserver (or a test environment): render immediately.
if (!('IntersectionObserver' in window)) { setVisible(true); return; }
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
setVisible(true);
io.disconnect();
}, { rootMargin: '400px 0px' });
io.observe(node);
return () => io.disconnect();
}, [visible]);
return (
<div ref={ref} className="remote-slot" style={{ '--slot-height': `${height}px` }}>
{visible ? children : <SlotSkeleton height={height} />}
</div>
);
}
A 400 px root margin is roughly half a phone viewport, which on a normal scroll gives the remote several hundred milliseconds of head start. Larger margins defeat the purpose; on a page with four deferred slots, a 2000 px margin loads all of them on first paint.
Step 4 — Give users who never scroll a way out #
Some users will never scroll, and some will never receive the intersection callback — a bot, a print view, a browser with the observer disabled. Both need a fallback that eventually loads the content.
// Load anyway once the page has been idle for a while.
useEffect(() => {
if (visible) return;
const idle = 'requestIdleCallback' in window
? requestIdleCallback(() => setVisible(true), { timeout: 8000 })
: setTimeout(() => setVisible(true), 4000);
return () => ('cancelIdleCallback' in window ? cancelIdleCallback(idle) : clearTimeout(idle));
}, [visible]);
Eight seconds is late enough that the critical path is long finished and early enough that a user scrolling slowly still finds content rather than a skeleton. For search-engine crawlers specifically, the safer answer is server-side rendering the deferred region rather than relying on a timer — see configuring Node federation for SSR remotes.
Step 5 — Make the skeleton accessible, not just visible #
A skeleton that is invisible to assistive technology turns a deferred region into a silent gap. Two attributes fix it.
function SlotSkeleton({ height }) {
return (
<div
className="slot-skeleton"
style={{ height }}
role="status"
aria-live="polite"
aria-busy="true"
>
<span className="visually-hidden">Loading recommendations…</span>
</div>
);
}
aria-busy tells a screen reader the region is incomplete, and the polite live region announces the content once it arrives without interrupting whatever the user is reading. Both are cheap and both are routinely missing.
Step 6 — Wrap each deferred remote in its own boundary #
Deferred remotes fail more often than eager ones, because they load later — after a deploy may have rotated the CDN asset, or after the user’s connection has changed. The failure must stay inside the slot.
<DeferredRemote name="recommendations" height={320}>
<RemoteErrorBoundary remote="recommendations" fallback={null}>
<Suspense fallback={<SlotSkeleton height={320} />}>
<Recommendations />
</Suspense>
</RemoteErrorBoundary>
</DeferredRemote>
A null fallback is often the right answer for a below-the-fold remote. If recommendations cannot load, showing nothing is better than showing an error card for content the user did not ask for. Collapse the reserved height once the boundary has decided it has failed, so a permanent empty box does not remain.
The boundary should still report the failure — see error boundary telemetry for remote apps. Silent visual degradation with loud telemetry is exactly the right combination here.
Step 7 — Budget deferred remotes separately #
Deferred remotes do not compete for Time to Interactive, so folding them into the same allowance as blocking remotes either penalises them unfairly or lets real weight hide. Track them under their own total.
'remote/recommend': { gzip: 60 * 1024, owner: 'discovery', deferred: true },
A useful secondary limit is main-thread time rather than bytes. A deferred remote that ships 60 kB but blocks the main thread for 400 ms while hydrating will produce visible jank mid-scroll, which is a worse experience than the same weight arriving during load. Measuring it is covered in measuring Time to Interactive with remote modules.
Verification #
- Nothing loads on first paint: open the Network panel, filter to the deferred remote’s origin, and confirm no request appears until you scroll.
- Layout does not shift: run Lighthouse and check that Cumulative Layout Shift stays under 0.1 with the deferred remotes on the page. Any jump means a slot height is wrong.
- The head start works: scroll at a normal speed on a throttled connection. The content should be present when the slot reaches the viewport, not a skeleton.
- The idle fallback fires: load the page and wait without scrolling. The remote should load within about eight seconds.
- Failure is contained: block the remote’s origin in DevTools, scroll to the slot, and confirm the rest of the page is unaffected and the slot collapses cleanly.
- Screen readers are told: with a screen reader running, confirm the loading state is announced and the arrival of content is announced once.
Troubleshooting #
Symptom: all deferred remotes load immediately.
Diagnosis: the rootMargin is large enough to intersect every slot on first paint, or the slots have zero height so they all sit at the same scroll position. Fix: reduce the margin to roughly half a viewport, and confirm each slot has its reserved min-height applied.
Symptom: content jumps when the remote mounts.
Diagnosis: the reserved height does not match the rendered height. Fix: measure the real rendered height at a few breakpoints and record it in the manifest rather than estimating it. If the remote’s height is genuinely variable, reserve the common case and let it grow downward only.
Symptom: the remote loads and unloads repeatedly while scrolling.
Diagnosis: the observer is not disconnected after the first intersection, and the component unmounts when scrolled out. Fix: call io.disconnect() on the first intersecting callback, and keep the loaded state rather than tying it to visibility.
Symptom: it works in the browser but the region is empty for crawlers.
Diagnosis: the crawler does not scroll and may not wait for the idle fallback. Fix: server-render the deferred region, or emit its content into the initial HTML and hydrate the interactive parts on visibility.