Lazy Loading with React Suspense

Published: April 18, 2026 · 12 min read

Lazy Loading Micro Frontend Components with React Suspense

Your host application loads nine remote micro frontends via Module Federation. The user logs in, lands on the dashboard — and waits 4 seconds staring at a blank screen. Every remote's remoteEntry.js is fetched, parsed, and executed before the first pixel renders, even though the user only needs the dashboard. Lazy loading micro frontend components with React.lazy() and Suspense solves this by loading each remote only when its route is visited — cutting the initial bundle by 77% and the time to interactive from 4.2 seconds to 1.4 seconds.

This is Article 16 in the Micro Frontend Architecture series and the final article in the React MFE section. If you haven't set up dynamic remote resolution yet, start with Dynamic Remote Loading.

Lazy loading React Suspense architecture diagram showing on-demand micro frontend loading with fallback states

The Problem — Loading All Remotes Upfront

When you import Module Federation remotes at the top of your host's App.jsx using standard ES module imports, Webpack resolves every remote immediately. Each remoteEntry.js triggers a network request regardless of whether the user will ever visit that route.

src/App.jsx
// src/App.jsx — Importing all MFE remotes eagerly (BAD)
import ProductsMFE from "Products/ProductsMFE";
import OrdersMFE from "Orders/OrdersMFE";
import InventoryMFE from "Inventory/InventoryMFE";
import PricingMFE from "Pricing/PricingMFE";
import EarningsMFE from "Earnings/EarningsMFE";
import AnalyticsMFE from "Analytics/AnalyticsMFE";
import ReportsMFE from "Reports/ReportsMFE";
import SettingsMFE from "Settings/SettingsMFE";
import SupportMFE from "Support/SupportMFE";

// Problem:
// ALL 9 remote entry scripts load on the initial page load.
// Each remoteEntry.js triggers a network request + JS parse + execution.
// The user sees a blank screen until EVERY remote finishes loading —
// even if they only need one MFE on the current route.
//
// With 9 remotes x ~50KB each = ~450KB of JavaScript
// parsed and executed before the first pixel renders.

This is eager loading — every remote pays its cost upfront. With nine remotes at ~50KB each, the user downloads and parses ~450KB of JavaScript they may never use. On slower connections (3G mobile), this adds 5-8 seconds to the initial load. The dashboard itself might be 30KB, but the user waits for all nine remotes before seeing it.

How React.lazy() and Suspense Work

React.lazy() (opens in a new tab) wraps a dynamic import() call and returns a component that React can render. The import only executes when the component is first rendered — not when the file is parsed. Suspense (opens in a new tab) provides a loading fallback while the lazy component's import is in flight.

The mechanism:

  1. React.lazy(() => import("...")) creates a lazy component that holds an unresolved promise
  2. When React tries to render the lazy component, it discovers the promise is pending
  3. React walks up the tree until it finds the nearest <Suspense> boundary
  4. Suspense renders the fallback prop instead of the lazy component
  5. When the import resolves, React re-renders with the actual component

For Module Federation remotes, the import("Products/ProductsMFE") call triggers Webpack's runtime to load the remote's remoteEntry.js, negotiate shared dependencies, and retrieve the exposed component — all deferred until the user navigates to that route.

Lazy Loading Module Federation Remotes

Replace standard imports with React.lazy() wrapping each Module Federation import. Add .catch() to handle load failures gracefully — the host application continues working even when a remote is unavailable.

src/App.jsx
// src/App.jsx — Lazy loading MFE remotes with React.lazy()
import React, { Suspense, lazy } from "react";

const ProductsMFE = lazy(() =>
  import("Products/ProductsMFE").catch((err) => {
    console.error("Failed to load ProductsMFE", err);
    return { default: () => <div>Failed to load Products Module</div> };
  })
);

const OrdersMFE = lazy(() =>
  import("Orders/OrdersMFE").catch((err) => {
    console.error("Failed to load OrdersMFE", err);
    return { default: () => <div>Failed to load Orders Module</div> };
  })
);

const InventoryMFE = lazy(() =>
  import("Inventory/InventoryMFE").catch((err) => {
    console.error("Failed to load InventoryMFE", err);
    return { default: () => <div>Failed to load Inventory Module</div> };
  })
);

const PricingMFE = lazy(() =>
  import("Pricing/PricingMFE").catch((err) => {
    console.error("Failed to load PricingMFE", err);
    return { default: () => <div>Failed to load Pricing Module</div> };
  })
);

const AnalyticsMFE = lazy(() =>
  import("Analytics/AnalyticsMFE").catch((err) => {
    console.error("Failed to load AnalyticsMFE", err);
    return { default: () => <div>Failed to load Analytics Module</div> };
  })
);

const SettingsMFE = lazy(() =>
  import("Settings/SettingsMFE").catch((err) => {
    console.error("Failed to load SettingsMFE", err);
    return { default: () => <div>Failed to load Settings Module</div> };
  })
);

const SupportMFE = lazy(() =>
  import("Support/SupportMFE").catch((err) => {
    console.error("Failed to load SupportMFE", err);
    return { default: () => <div>Failed to load Support Module</div> };
  })
);

// Each MFE's remoteEntry.js loads ONLY when the user
// navigates to that route — not on initial page load.
// The .catch() handles network failures gracefully
// by returning a fallback component instead of crashing.

The .catch() return value must have a default property. React.lazy() expects the import to resolve to a module with a default export. The .catch() handler returns { default: () => <div>...</div> } — a module shape with a fallback component as the default export. Without this shape, React throws a "lazy component resolved to undefined" error.

Building Loading Fallback Components

The <Suspense fallback={}> prop controls what the user sees while a lazy component loads. Two common approaches: a simple spinner for quick transitions, and a shimmer skeleton that mirrors the expected page layout.

components/LoadingFallback.jsx
// components/LoadingFallback.jsx — Simple spinner fallback
function LoadingFallback() {
  return (
    <div className="flex items-center justify-center h-screen">
      <div className="text-center">
        <div
          className="animate-spin rounded-full h-12 w-12
          border-b-2 border-blue-600 mx-auto"
        ></div>
        <p className="mt-4 text-gray-600">Loading...</p>
      </div>
    </div>
  );
}

export default LoadingFallback;

A spinner works well for short load times (under 500ms). It signals "something is loading" without committing to a specific layout. Use this as the default fallback when you don't know the remote component's layout in advance — which is common in micro frontend architectures where each team owns their MFE's design.

⚠️

Shimmer skeletons work best when the host team knows the remote's layout. In micro frontend architectures with independent teams, the remote's layout can change without the host knowing. If the shimmer no longer matches the real layout, the visual transition feels worse than a simple spinner. Default to spinners unless you have a stable contract for the remote's page structure.

ErrorBoundary for Render-Time Errors

The .catch() on the lazy import only handles load-time errors — network failures, 404 responses, remote server downtime. But what about errors that happen after the component loads successfully? A remote component might throw during rendering due to null references, missing props, or a shared dependency version mismatch.

React's Error Boundaries (opens in a new tab) catch render-time errors and display a recovery UI instead of crashing the entire host application.

components/ErrorBoundary.jsx
// components/ErrorBoundary.jsx
// Catches render-time errors in lazy-loaded MFE components
import React, { Component } from "react";

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    console.error("ErrorBoundary caught an error:", error, errorInfo);
  }

  handleReset = () => {
    this.setState({ hasError: false, error: null });
    window.location.reload();
  };

  render() {
    if (this.state.hasError) {
      return (
        <div style={{
          display: "flex", alignItems: "center",
          justifyContent: "center", minHeight: "400px", padding: "20px",
        }}>
          <div style={{
            maxWidth: "600px", textAlign: "center", padding: "40px",
            backgroundColor: "#fff", borderRadius: "12px",
            boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
          }}>
            <h1 style={{ fontSize: "32px", marginBottom: "16px", color: "#1a1a2e" }}>
              Something went wrong
            </h1>
            <p style={{ fontSize: "18px", color: "#666", marginBottom: "24px" }}>
              We encountered an error while loading this component.
            </p>
            {this.state.error && (
              <details style={{
                textAlign: "left", backgroundColor: "#f5f5f5",
                padding: "16px", borderRadius: "8px", marginBottom: "24px",
              }}>
                <summary style={{ cursor: "pointer", fontWeight: "bold" }}>
                  Error Details
                </summary>
                <pre style={{ fontSize: "14px", color: "#d32f2f", overflow: "auto" }}>
                  {this.state.error.toString()}
                </pre>
              </details>
            )}
            <div style={{ display: "flex", gap: "12px", justifyContent: "center" }}>
              <button onClick={this.handleReset} style={{
                padding: "12px 24px", fontSize: "16px", fontWeight: "bold",
                color: "white", backgroundColor: "#667eea",
                border: "none", borderRadius: "8px", cursor: "pointer",
              }}>
                Reload Page
              </button>
              <button onClick={() => (window.location.href = "/")} style={{
                padding: "12px 24px", fontSize: "16px", fontWeight: "bold",
                color: "white", backgroundColor: "#1a1a2e",
                border: "none", borderRadius: "8px", cursor: "pointer",
              }}>
                Go Home
              </button>
            </div>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

The ErrorBoundary provides two recovery paths: Reload Page resets the error state and refreshes (useful for transient errors), and Go Home navigates to the root (useful when the remote is persistently broken). The collapsible error details help developers debug in development without overwhelming end users.

The Complete Pattern — ErrorBoundary + Suspense + Lazy

Three layers handle three different failure modes. Here is how they fit together in the host application's route configuration:

error-handling-flow.txt
Error Handling Layers in Lazy Loaded Micro Frontends:

Layer 1: .catch() on dynamic import
  Catches: Network errors, 404, remote server down, DNS failure
  When: During the import() call — before the component exists
  Returns: Fallback component ({ default: () => <Fallback /> })
  Example: import("Products/ProductsMFE").catch(err => fallback)

Layer 2: <Suspense fallback={}>
  Catches: Nothing — Suspense does NOT catch errors
  Purpose: Shows loading UI while the lazy component resolves
  When: Between import() start and component render
  Renders: <LoadingFallback /> until the promise resolves

Layer 3: <ErrorBoundary>
  Catches: Render-time errors AFTER the component loads successfully
  When: The component loaded but throws during render()
  Examples: Null reference, missing props, incompatible React version,
            shared dependency version mismatch
  Renders: Error UI with "Reload" and "Go Home" buttons

Execution order:
  1. User navigates to /products
  2. React.lazy() triggers import("Products/ProductsMFE")
  3. Suspense shows <LoadingFallback /> immediately
  4a. If import fails → .catch() returns fallback component
  4b. If import succeeds → Suspense renders the component
  5. If component throws during render → ErrorBoundary catches it

The order matters: ErrorBoundary wraps Suspense, which wraps the lazy component. If you reverse ErrorBoundary and Suspense, the ErrorBoundary cannot catch errors thrown by the Suspense boundary itself.

src/App.jsx
// src/App.jsx — Route-level Suspense boundaries (RECOMMENDED)
import React, { Suspense, lazy } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import LoadingFallback from "./components/LoadingFallback";
import ErrorBoundary from "./components/ErrorBoundary";
import AppLayout from "./components/AppLayout";
import DashboardPage from "./components/Dashboard";

const ProductsMFE = lazy(() =>
  import("Products/ProductsMFE").catch((err) => {
    console.error("Failed to load ProductsMFE", err);
    return { default: () => <div>Failed to load Products Module</div> };
  })
);
const OrdersMFE = lazy(() =>
  import("Orders/OrdersMFE").catch((err) => {
    console.error("Failed to load OrdersMFE", err);
    return { default: () => <div>Failed to load Orders Module</div> };
  })
);
const InventoryMFE = lazy(() =>
  import("Inventory/InventoryMFE").catch((err) => {
    console.error("Failed to load InventoryMFE", err);
    return { default: () => <div>Failed to load Inventory Module</div> };
  })
);
const SettingsMFE = lazy(() =>
  import("Settings/SettingsMFE").catch((err) => {
    console.error("Failed to load SettingsMFE", err);
    return { default: () => <div>Failed to load Settings Module</div> };
  })
);

function App() {
  return (
    <Routes>
      <Route path="/" element={<Navigate to="/home" replace />} />

      <Route path="/*" element={<AppLayout />}>
        <Route path="home" element={<DashboardPage />} />

        {/* Each MFE route has its own Suspense + ErrorBoundary */}
        <Route
          path="products/*"
          element={
            <ErrorBoundary>
              <Suspense fallback={<LoadingFallback />}>
                <ProductsMFE />
              </Suspense>
            </ErrorBoundary>
          }
        />
        <Route
          path="orders/*"
          element={
            <ErrorBoundary>
              <Suspense fallback={<LoadingFallback />}>
                <OrdersMFE />
              </Suspense>
            </ErrorBoundary>
          }
        />
        <Route
          path="inventory/*"
          element={
            <ErrorBoundary>
              <Suspense fallback={<LoadingFallback />}>
                <InventoryMFE />
              </Suspense>
            </ErrorBoundary>
          }
        />
        <Route
          path="settings/*"
          element={
            <ErrorBoundary>
              <Suspense fallback={<LoadingFallback />}>
                <SettingsMFE />
              </Suspense>
            </ErrorBoundary>
          }
        />
      </Route>

      <Route path="*" element={<Navigate to="/home" replace />} />
    </Routes>
  );
}

export default App;

Each MFE route has its own ErrorBoundary > Suspense > LazyComponent stack. If the Products remote crashes, only the products content area shows the error UI — the sidebar, header, and other routes continue working normally.

Granular vs App-Level Suspense Boundaries

A common mistake is wrapping all routes in a single Suspense boundary at the app level.

src/App.jsx
// src/App.jsx — App-level Suspense wrapping ALL routes (NOT recommended)
import React, { Suspense, lazy } from "react";
import { Routes, Route } from "react-router-dom";
import LoadingFallback from "./components/LoadingFallback";

const ProductsMFE = lazy(() => import("Products/ProductsMFE"));
const OrdersMFE = lazy(() => import("Orders/OrdersMFE"));
const InventoryMFE = lazy(() => import("Inventory/InventoryMFE"));

function App() {
  return (
    // One Suspense wrapping ALL routes
    <Suspense fallback={<LoadingFallback />}>
      <Routes>
        <Route path="products/*" element={<ProductsMFE />} />
        <Route path="orders/*" element={<OrdersMFE />} />
        <Route path="inventory/*" element={<InventoryMFE />} />
      </Routes>
    </Suspense>
  );
}

// Problems with app-level Suspense:
//
// 1. ANY route change that triggers a lazy load shows the SAME
//    full-screen loading spinner — even if the sidebar and header
//    are already rendered and should stay visible.
//
// 2. If one remote fails, the entire fallback replaces the page.
//    The sidebar, header, and other static content disappear.
//
// 3. No isolation — a slow remote blocks rendering of everything
//    inside the Suspense boundary, including non-lazy content.
//
// 4. No per-route error handling without individual ErrorBoundaries.

Suspense boundary comparison showing granular route-level vs app-level boundaries in lazy loaded micro frontend architecture

Route-level boundaries (one Suspense per MFE route) provide better UX because:

AspectApp-Level SuspenseRoute-Level Suspense
Loading stateEntire page replaced by spinnerOnly content area shows spinner
Shell layoutSidebar/header disappear during loadSidebar/header stay visible
Error isolationOne failing remote hides all contentOnly the broken route shows error UI
NavigationUser loses orientation during transitionsUser sees consistent shell layout
ErrorBoundaryOne boundary for all routesPer-route error handling and recovery

The sidebar and header should never flash a loading spinner. These are part of the host shell — they render from the host's own bundle, not from a remote. Wrapping them inside a Suspense boundary alongside lazy remotes causes them to disappear during route transitions. Keep Suspense boundaries inside the route content area, not around the layout.

Lazy Loading in Next.js — next/dynamic vs React.lazy

If your host application uses Next.js with NextFederationPlugin (opens in a new tab), you use next/dynamic instead of React.lazy(). The pattern is similar but has key differences for server-side rendering.

pages/product/[id]/index.tsx
// pages/product/[id]/index.tsx — Next.js lazy loading with next/dynamic
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { Suspense, useCallback } from "react";
import ErrorBoundary from "../../../components/ErrorBoundary";

// Dynamic import of remote MFE component — client-side only
const RemoteProductDetailPage = dynamic(
  () =>
    import("Products/ProductDetailPage").catch(() => {
      return { default: () => <OfflineState /> };
    }),
  {
    loading: () => <LoadingState />,
    ssr: false,
  }
);

function LoadingState() {
  return (
    <div style={{
      display: "flex", flexDirection: "column",
      alignItems: "center", justifyContent: "center",
      minHeight: "300px", gap: "20px",
    }}>
      <div style={{
        width: "50px", height: "50px",
        border: "5px solid #f3f3f3",
        borderTop: "5px solid #667eea",
        borderRadius: "50%",
        animation: "spin 1s linear infinite",
      }}></div>
      <p>Loading Product Details...</p>
    </div>
  );
}

function OfflineState() {
  return (
    <div style={{
      display: "flex", flexDirection: "column",
      alignItems: "center", justifyContent: "center",
      minHeight: "400px", textAlign: "center",
    }}>
      <h2>Product Unavailable</h2>
      <p>Unable to load product details. Please try again later.</p>
      <button onClick={() => window.location.reload()}>Retry</button>
    </div>
  );
}

export default function ProductDetailPage() {
  const router = useRouter();
  const { id } = router.query;

  const handleNavigate = useCallback(
    (path) => {
      if (path) router.push(path);
    },
    [router]
  );

  return (
    <ErrorBoundary>
      <Suspense fallback={<LoadingState />}>
        <RemoteProductDetailPage
          productNumericId={id}
          handleNavigate={handleNavigate}
        />
      </Suspense>
    </ErrorBoundary>
  );
}

// Key patterns:
// - next/dynamic with ssr: false — remote MFEs cannot server-render
// - loading prop provides fallback DURING the dynamic import
// - Suspense wraps the rendered component for React-level fallback
// - ErrorBoundary catches render-time errors AFTER the component loads
// - .catch() on import handles load-time errors (network, remote down)
// - Props (productNumericId, handleNavigate) pass data to the remote

React.lazy vs next/dynamic comparison chart for lazy loading micro frontend components

FeatureReact.lazy()next/dynamic
Importimport { lazy } from "react"import dynamic from "next/dynamic"
Loading fallback<Suspense fallback={<Spinner />}>{ loading: () => <Spinner /> }
SSR supportNo (client-side only)Yes by default, disable with ssr: false
Error handling.catch() + ErrorBoundary.catch() + ErrorBoundary
When component loadsOn first render (when route matches)On first render (when page loads)
Chunk namingwebpackChunkName magic commentAutomatic based on import path
Use for MFE remotesYes — wrap Module Federation importYes — wrap Module Federation import
SSR for MFE remotesNot applicableMust set ssr: false (remotes are client-only)
Config filewebpack.config.jsnext.config.js
Suspense requiredYes — must wrap in <Suspense>Optional — loading prop handles it

The critical difference: ssr: false is mandatory for Module Federation remotes in Next.js. Remote entry scripts run in the browser — they attach containers to the window object, which does not exist during server-side rendering. Without ssr: false, Next.js attempts to import the remote on the server, fails to find window, and throws a build error.

Performance Impact

Lazy loading transforms the loading profile of a micro frontend application from "download everything upfront" to "download on demand."

performance-comparison.txt
Performance Impact — Lazy Loading vs Eager Loading:

Eager Loading (import at top of file):
  Initial bundle:         1.8 MB (host + ALL remote entry scripts)
  Time to Interactive:    4.2 seconds
  First Contentful Paint: 3.1 seconds
  Wasted bandwidth:       ~1.2 MB (remotes the user never visits)

Lazy Loading (React.lazy + Suspense):
  Initial bundle:         420 KB (host shell only)
  Time to Interactive:    1.4 seconds
  First Contentful Paint: 0.9 seconds
  Per-route overhead:     50-150 KB per remote (loaded on demand)

Improvement:
  Bundle size reduction:  -77% on initial load
  TTI improvement:        -67% faster
  FCP improvement:        -71% faster

Most users visit 2-3 routes per session.
With 9 remotes, 60-70% of remote code is never downloaded.

The key insight: most users visit 2-3 routes per session. With 9 remotes, eager loading forces every user to download 6-7 remotes they never use. Lazy loading shifts that cost to zero — unused remotes are never fetched.

To further reduce the on-demand loading delay, combine lazy loading with the preloading strategies covered in the previous articlerequestIdleCallback can prefetch remote entries for likely navigation targets during browser idle time, so the remote is already cached when the user clicks.

What's Next?

This completes the React MFE section of the series. You have built a full React micro frontend architecture: monorepo setup, shared packages, Webpack 5 configuration, Tailwind CSS, Module Federation, host and remote apps, shared dependencies, dynamic remote loading, and lazy loading with Suspense.

The next section covers Next.js micro frontends — how NextFederationPlugin differs from ModuleFederationPlugin, how to set up a Next.js host with React remotes, and how SSR changes the Module Federation setup.

← Back to Dynamic Remote Loading

Continue to NextFederationPlugin vs ModuleFederationPlugin →


Frequently Asked Questions

What is lazy loading in micro frontend architecture?

Lazy loading in micro frontend architecture means loading each remote MFE module only when the user navigates to its route, instead of downloading all remote entry scripts on the initial page load. In React, this is done using React.lazy() which wraps the dynamic import() call for Module Federation remotes. Combined with Suspense for loading states and ErrorBoundary for error handling, lazy loading reduces the initial bundle size by 60-80% — the host shell loads in under 500KB while each remote loads on demand at 50-150KB per route.

How do I lazy load a micro frontend component with React.lazy?

Wrap the Module Federation import in React.lazy() with a .catch() handler: const ProductsMFE = lazy(() => import("Products/ProductsMFE").catch(err => ({ default: () => <div>Failed to load</div> }))). Then wrap the component in <Suspense> with a fallback: <Suspense fallback={<LoadingFallback />}><ProductsMFE /></Suspense>. The .catch() handles load-time failures (network errors, remote server down), while Suspense shows a loading spinner during the import. Add an ErrorBoundary around Suspense to catch render-time errors after the component loads.

What is the difference between React.lazy and next/dynamic for micro frontends?

React.lazy() requires wrapping the component in a <Suspense> boundary for loading states, while next/dynamic accepts a loading prop directly in its options object. The key difference for micro frontends is SSR: next/dynamic supports server-side rendering by default and must be explicitly disabled with ssr: false for Module Federation remotes (since remote entry scripts are client-side only). React.lazy() is client-side only by design. Both use the same .catch() pattern for error handling and both work with ErrorBoundary for render-time errors. Use React.lazy() in React MFE hosts and next/dynamic in Next.js MFE hosts.

Why do I need ErrorBoundary with lazy loaded micro frontends?

ErrorBoundary and .catch() handle different failure points. The .catch() on the dynamic import handles load-time errors — network failures, 404 responses, remote server downtime. ErrorBoundary catches render-time errors — when the component loaded successfully but throws during rendering due to null references, missing props, incompatible shared dependency versions, or React version mismatches between host and remote. Without ErrorBoundary, a render-time error in one MFE crashes the entire host application. With ErrorBoundary wrapping each route, the error is isolated — the user sees a recovery UI with Reload and Go Home options while the rest of the application continues working.

Should I use one Suspense boundary or multiple Suspense boundaries for micro frontends?

Use multiple route-level Suspense boundaries — one per MFE route. A single app-level Suspense boundary causes the entire page (sidebar, header, footer) to be replaced by the loading fallback whenever any lazy component loads. With route-level boundaries, only the content area shows a loading spinner while the shell layout stays visible. This provides a better user experience because the sidebar navigation remains interactive during route transitions, each MFE has its own loading and error state, and a slow or failing remote does not affect the rest of the application.