Lazy-Loading Remote Modules

How React.lazy and dynamic import wire up to module federation — and why a remote team can deploy without the shell ever redeploying.

March 21, 20264 min read4 / 6

The mechanism that makes runtime composition work is simpler than it looks. At its core, it's the browser's native dynamic import() — module federation just extends it to work across deployment boundaries.

The Import Side

In a standard Vite or Webpack app, lazy loading a heavy component looks like this:

TSX
const HeavyChart = React.lazy(() => import('./components/HeavyChart'));

Vite will code-split at that import boundary — HeavyChart.tsx ships in a separate chunk, loaded only when the component is first rendered.

With module federation, the same pattern applies, but the import resolves to a remote bundle rather than a local file:

TSX
const AnalyticsDashboard = React.lazy( () => import('remote-analytics/Dashboard') );

remote-analytics/Dashboard doesn't exist in the host's source tree. The module federation runtime intercepts that import, looks up remote-analytics in the manifest (which points to localhost:3001 in development, or a CDN URL in production), fetches the remote bundle, and resolves the module.

The React code in the host app is identical in both cases. The difference is entirely in what the module federation runtime does with the import.

Wrapping It in Suspense

Because the remote is loaded asynchronously, the component needs a Suspense boundary:

TSX
function App() { return ( <ErrorBoundary fallback={<RemoteError />}> <Suspense fallback={<LoadingSpinner />}> <AnalyticsDashboard /> </Suspense> </ErrorBoundary> ); }

The ErrorBoundary matters more here than in a standard app — if the remote is unavailable (CDN down, bad deploy, network issue), the import will fail. Without an error boundary, that failure propagates up the entire React tree. With one, the blast radius is contained to this component.

The Deploy Implication

Here's what I find genuinely compelling about this pattern: the shell doesn't need to redeploy when a remote team ships a change.

When the remote team deploys a new version of their bundle, it lands at the same CDN URL. The next time a user loads the app (or the component is rendered), the host fetches the current version from that URL — which now points to the new code. The shell did nothing.

There's a real use case for this even in simpler systems. I worked on a product that had both a core application and a marketing site. We wanted the signup form on the marketing site to look and feel like the app. The options were:

  1. Publish the design system as an npm package → bump in app → publish to npm → pull into marketing site → bump that version → run tests again
  2. Bundle the components separately as part of the app build → put on CDN → import on the marketing site

Option 2 means deploying the app automatically updates the form on the marketing site. No separate marketing site deploy. The tradeoff is that the marketing site's form is now live-linked to whatever the app deploys — which is fine if you trust your own release process.

The Failure Mode

The cost of this autonomy is reliability. With a monolith, deploys are binary: it went out or it didn't. With runtime composition, any individual remote can fail independently.

"No internet" is frustrating. "Flaky internet" is worse. The same principle applies here: a hard failure (remote completely down) is at least predictable. Partial failures — the remote loads sometimes, fails other times — are much harder to debug, especially when there are multiple remotes in play.

Error boundaries help contain the failure. Monitoring (which we'll cover later) helps detect it. But the failure mode is structurally different from a monolith, and it's worth being clear-eyed about that when evaluating the trade.

The Constraint the exposes Config Enforces

One thing the configuration does that I didn't fully appreciate until I used it: the exposes field in the remote config is an API contract. If remote-analytics only exposes ./Dashboard, the host can only import remote-analytics/Dashboard. Trying to import remote-analytics/src/internal-utils won't work.

This is the same principle as exports in package.json — you define your public API surface, and everything else is private. For large teams, this matters. It prevents one team from depending on another team's implementation details, which is how you end up with impossible-to-change internal APIs.

Next: what happens when teams need to share dependencies, and why React version conflicts are the specific pain you'll almost certainly hit.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.