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.

Classifying remotes by when they should load Each class of remote needs a different trigger, and applying one strategy to all of them makes either the page slow or the content missing. Remote position Load when Because Above the fold eagerly, on route entry deferring shows an empty page Below the fold on visibility, with margin it is not on the critical path Behind a tab or drawer on the interaction most users never open it Inside a modal on open zero cost until it is needed
Classification is per route: the same widget can be above the fold on one page and behind a tab on another.

Prerequisites #

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 #

Why reserving layout comes before deferring Deferring without a reserved slot converts a load-time cost into a scroll-time layout shift, which users notice far more. Deferred with no reserved height Slot renders at zero height Remote mounts and pushes content down Cumulative Layout Shift spikes Users lose their scroll position Deferred with a reserved slot Slot renders at the committed height Skeleton occupies the final footprint Content swaps in place, no shift Scroll position stays stable
The height has to come from the remote team and live in the manifest — a host-side guess drifts the first time the design changes.

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 #

How a deferred remote should fail Below-the-fold content the user did not request should degrade to nothing rather than to an error card, while still reporting loudly. The deferred remote failed to load. Now what? Network error Retry once, quietly Chunk may have rotated User sees the skeleton Still failing Collapse the slot Render nothing Report to telemetry Always Keep the rest of the page Boundary contains it No blank page
Quiet visually, loud in telemetry — the opposite of what an uncaught error gives you.

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 #

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.