Error handling for remote module rendering in Module Federation.

Version Requirements

This solution requires upgrading the @module-federation/runtime version to 0.8.10 or higher.

Background

Remote module loading process may fail due to network resource loading failure or business logic rendering failure.

Though Module Federation Runtime provides detailed error log information and runtime hook to help users locate the cause of loading failure, we need to add error fallback mechanism to ensure the stability of the entire site, preventing the entire site from crashing due to the failure of a remote module rendering.

Solution

To build a robust remote module loading mechanism, we can handle possible problems from the following three levels:

The following solutions can refer to the examples in router-demo.

Network layer: Retry mechanism

Use @module-federation/retry-plugin plugin to handle network related issues:

  • Automatically retry failed resource requests

  • Configurable retry times and interval

  • Support custom error handling strategy

Loading layer: Error handling hook

Use Module Federation Runtime provided errorLoadRemote hook for more granular error handling:

  • Capture errors in different loading lifecycle
  • Provide fallback component or backup resource
  • Support custom error handling strategy

Rendering layer: Error boundary

Use React's ErrorBoundary mechanism to handle component rendering exceptions:

  • Graceful degradation, display friendly error提示
  • Isolate error impact, prevent the entire application from crashing
  • Support error recovery and retry loading

These three solutions are for different scenarios, can be used separately, or combined to provide a more完善的错误处理机制。下面我们将详细介绍每种方案的具体实现。

Add retry mechanism

For weak network environment or producer has not started the service, we can add retry mechanism to request resources multiple times, which will increase the probability of resource loading success.

Module Federation official provides retry plugin @module-federation/retry-plugin to support retry mechanism for resources, support fetch and script resources retry.

Pure runtime registration


import React from 'react';
import { createInstance, loadRemote } from '@module-federation/enhanced/runtime';
import { RetryPlugin } from '@module-federation/retry-plugin';

const mf = createInstance({
  name: 'host',
  remotes: [
      {
          name: "remote1",
          alias: "remote1"
          entry: "http://localhost:2001/mf-manifest.json",
      }
  ],
  plugins: [
    RetryPlugin({
      retryTimes: 3,
      retryDelay: 1000,
      manifestDomains: ['https://domain1.example.com', 'https://domain2.example.com'],
      domains: ['https://cdn1.example.com', 'https://cdn2.example.com'],
      addQuery: ({ times, originalQuery }) => `${originalQuery}&retry=${times}`,
      onRetry: ({ times, url }) => console.log('retry', times, url),
      onSuccess: ({ url }) => console.log('success', url),
      onError: ({ url }) => console.log('error', url),
    }),
  ]
});

// Module loading
const Remote1Button = React.lazy(() => mf.loadRemote('remote1/button'));

export default () => {
  return (
    <React.Suspense fallback={<div> Loading Remote1App...</div>}>
      <Remote1Button />
    </React.Suspense>
  );
}

// Method/function loading
mf.loadRemote<{add: (...args: Array<number>)=> number }>("remote1/util").then((md)=>{
    md.add(1,2,3);
});

More about the parameter configuration of @module-federation/retry-plugin please see document

Plugin registration

import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [
    pluginReact(),
    pluginModuleFederation({
      name: 'host',
      runtimePlugins: [
        path.join(__dirname, './src/runtime-plugin/retry.ts'),
      ],
    }),
  ],
});
// src/runtime-plugin/retry.ts
import { RetryPlugin } from '@module-federation/retry-plugin';

const retryPlugin = () =>
  RetryPlugin({
    retryTimes: 3,
    retryDelay: 1000,
    manifestDomains: ['https://domain1.example.com', 'https://domain2.example.com'],
    domains: ['https://cdn1.example.com', 'https://cdn2.example.com'],
    addQuery: ({ times, originalQuery }) => `${originalQuery}&retry=${times}`,
    onRetry: ({ times, url }) => console.log('retry', times, url),
    onSuccess: ({ url }) => console.log('success', url),
    onError: ({ url }) => console.log('error', url),
  });
export default retryPlugin;

Effect as follows:

Block network request
Block then Enable

errorLoadRemote hook

For errors in the loading process of remote modules, they can be captured in the errorLoadRemote hook.

errorLoadRemote is the hook for error handling in Module Federation Runtime. When the remote module loading fails, this hook will be triggered. It is designed to trigger when the module loading fails in different lifecycle stages, and allow users to customize error handling strategies.

errorLoadRemote supports returning a fallback component for error fallback, and also supports returning specific resource content to ensure subsequent processes render normally.

We divide the usage of module registration and loading into "pure runtime + dynamic import" and "plugin registration + synchronous import".

Pure runtime + dynamic import

When using pure runtime registration, the remote module will not request resources until after registration.

import React from 'react';
import { createInstance, loadRemote } from '@module-federation/enhanced/runtime';
import { RetryPlugin } from '@module-federation/retry-plugin';

// Module registration
const mf = createInstance({
    name: 'host',
    remotes: [
        {
            name: "remote1",
            entry: "http://localhost:2001/mf-manifest.json",
            alias: "remote1"
        }
    ],
    plugins: [
      RetryPlugin({
        retryTimes: 3,
        retryDelay: 1000,
      }),
    ]
});

// Module loading
const Remote1Button = React.lazy(() => loadRemote('remote1/button'));

export default () => {
  return (
    <React.Suspense fallback={<div> Loading Remote1App...</div>}>
      <Remote1Button />
    </React.Suspense>
  );
}

// Method/function loading
loadRemote<{add: (...args: Array<number>)=> number }>("remote1/util").then((md)=>{
    md.add(1,2,3);
});

Use the errorLoadRemote hook to capture remote module loading errors (including resource loading errors), and support returning errorBoundary fallback component.

import React from 'react';
import { createInstance, loadRemote } from '@module-federation/enhanced/runtime';
import { RetryPlugin } from '@module-federation/retry-plugin';

const fallbackPlugin: () => ModuleFederationRuntimePlugin = function () {
  return {
    name: 'fallback-plugin',
    errorLoadRemote(args) {
      return { default: () => <div> fallback component </div> };
    },
  };
};

// Module registration
const mf = createInstance({
  name: 'host',
  remotes: [
      {
          name: "remote1",
          alias: "remote1"
          entry: "http://localhost:2001/mf-manifest.json",
      }
  ],
  plugins: [
    RetryPlugin({
      retryTimes: 3,
      retryDelay: 1000,
    }),
    fallbackPlugin()
  ]
});

// Module loading
const Remote1Button = React.lazy(() => mf.loadRemote('remote1/button'));

export default () => {
  return (
    <React.Suspense fallback={<div> Loading Remote1App...</div>}>
      <Remote1Button />
    </React.Suspense>
  );
}

// Method/function loading
mf.loadRemote<{add: (...args: Array<number>)=> number }>("remote1/util").then((md)=>{
    md.add(1,2,3);
});

Effect as follows:

Block network request
Block then Enable

Plugin registration + synchronous import

Plugin registration supports using synchronous import to load modules, and the resource request timing is earlier than pure runtime, so we need to register the errorLoadRemote hook in the plugin.

// rsbuild.config.ts
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [
    pluginReact(),
    pluginModuleFederation({
      name: 'host',
      remotes: {
        remote1: 'remote1@http://localhost:2001/mf-manifest.json',
      },
      runtimePlugins: [
        path.join(__dirname, './src/runtime-plugin/retry.ts'),
        path.join(__dirname, './src/runtime-plugin/fallback.ts'),
      ],
      ...
    }),
  ],
});
Tips

The following example shows how to handle errors in different lifecycle stages.

If your application scenario is simple, you can refer to the simplified version below, which provides a unified error handling solution.

// src/runtime-plugin/fallback.ts
import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime';

interface FallbackConfig {
  // Backup service address
  backupEntryUrl?: string;
  // Custom error message
  errorMessage?: string;
}

const fallbackPlugin = (config: FallbackConfig = {}): ModuleFederationRuntimePlugin => {
  const {
    backupEntryUrl = 'http://localhost:2002/mf-manifest.json',
    errorMessage = 'Module loading failed, please try again later'
  } = config;

  return {
    name: 'fallback-plugin',
    async errorLoadRemote(args) {
      // Handle component loading errors
      if (args.lifecycle === 'onLoad') {
        const React = await import('react');

        // Create a fallback component with error message
        const FallbackComponent = React.memo(() => {
          return React.createElement(
            'div',
            {
              style: {
                padding: '16px',
                border: '1px solid #ffa39e',
                borderRadius: '4px',
                backgroundColor: '#fff1f0',
                color: '#cf1322'
              }
            },
            errorMessage
          );
        });

        FallbackComponent.displayName = 'ErrorFallbackComponent';

        return () => ({
          __esModule: true,
          default: FallbackComponent
        });
      }

      // Handle entry file loading errors
      if (args.lifecycle === 'afterResolve') {
        try {
          // Try to load backup service
          const response = await fetch(backupEntryUrl);
          if (!response.ok) {
            throw new Error(`Failed to fetch backup entry: ${response.statusText}`);
          }
          const backupManifest = await response.json();
          console.info('Successfully loaded backup manifest');
          return backupManifest;
        } catch (error) {
          console.error('Failed to load backup manifest:', error);
          // If backup service also fails, return original error
          return args;
        }
      }

      return args;
    },
  };
};

export default fallbackPlugin;
  • App.tsx synchronous import: import Remote1App from 'remote1/app';

  • About fallback.ts:

    • errorLoadRemote hook receives an args parameter, which contains the detailed information of the error. Through args.lifecycle we can determine the stage of the error occurrence, and take the corresponding processing strategy:

    • Handle component loading errors (args.lifecycle === 'onLoad')

      • This type of error occurs in the module loading process other than the entry resource mf-manifest.json
      • We can return a fallback component with styles:
      if (args.lifecycle === 'onLoad') {
        const React = await import('react');
        const FallbackComponent = React.memo(() => {
          return React.createElement(
            'div',
            {
              style: {
                padding: '16px',
                border: '1px solid #ffa39e',
                borderRadius: '4px',
                backgroundColor: '#fff1f0',
                color: '#cf1322'
              }
            },
            'fallback component'
          );
        });
        FallbackComponent.displayName = 'ErrorFallbackComponent';
        return () => ({
          __esModule: true,
          default: FallbackComponent
        });
      }
    • Handle entry file loading errors (args.lifecycle === 'afterResolve')

      • This type of error occurs in the entry resource mf-manifest.json loading process
      • We can handle it in the following two ways:

      a. Try to load backup service:

      if (args.lifecycle === 'afterResolve') {
        try {
          const response = await fetch('http://localhost:2002/mf-manifest.json');
          if (!response.ok) {
            throw new Error(`Failed to fetch backup entry: ${response.statusText}`);
          }
          const backupManifest = await response.json();
          console.info('Successfully loaded backup manifest');
          return backupManifest;
        } catch (error) {
          console.error('Failed to load backup manifest:', error);
          return args;
        }
      }

      b. Use local backup resource:

      if (args.lifecycle === 'afterResolve') {
        // Use predefined backup清单
        const backupManifest = {
           id: 'fallback',
           name: 'fallback',
           metaData: {
             name: 'fallback',
             type: 'app',
             buildInfo: {
               buildVersion: 'local',
               buildName: 'fallback',
             },
             remoteEntry: {
               name: 'remoteEntry.js',
               path: '',
               type: 'global',
             },
             types: {
               path: '',
               name: '',
               zip: '@mf-types.zip',
               api: '@mf-types.d.ts',
             },
             globalName: 'fallback',
             pluginVersion: '1',
             prefetchInterface: false,
             publicPath: 'https://example.com/',
           },
           shared: [],
           remotes: [],
           exposes: [],
         };
        return backupManifest;
      }
    • Simplified version

      If you don't need to distinguish between error types, you can also use a generic error handling solution:

    import type { ModuleFederationRuntimePlugin } from '@module-federation/runtime';
    
    const fallbackPlugin = (errorMessage = 'Module loading failed, please try again later'): ModuleFederationRuntimePlugin => {
      return {
        name: 'fallback-plugin',
        async errorLoadRemote() {
          const React = await import('react');
          const FallbackComponent = React.memo(() => {
            return React.createElement(
              'div',
              {
                style: {
                  padding: '16px',
                  border: '1px solid #ffa39e',
                  borderRadius: '4px',
                  backgroundColor: '#fff1f0',
                  color: '#cf1322'
                }
              },
              errorMessage
            );
          });
          FallbackComponent.displayName = 'ErrorFallbackComponent';
          return () => ({
            __esModule: true,
            default: FallbackComponent
          });
        },
      };
    };
    export default fallbackPlugin;

Effect as follows:

Block network request
Block then Enable

Set ErrorBoundary for component

React's ErrorBoundary is the last line of defense for handling component-level errors, in the dynamic loading scenario of remote modules (such as lazy loading), it can help us capture and process the rendering errors of remote modules and provide graceful degradation.

Setting ErrorBoundary for components is suitable for dynamic import remote module scenarios, such as lazy loading scenarios.

Moreover, after setting ErrorBoundary for components, you can avoid relying on the errorLoadRemote hook for error fallback, which is a feature of React itself to provide error fallback for your components.

  • App.tsx dynamic import remote module
// App.tsx
import React, {
  useRef,
  useEffect,
  ForwardRefExoticComponent,
  Suspense,
} from 'react';

const Remote1AppWithLoadRemote = React.lazy(() => loadRemote('remote1/app'));
const Remote1AppWithErrorBoundary = React.forwardRef<any, any>((props, ref) => (
  <ErrorBoundary fallback={<div>Error loading Remote1App...</div>}>
    <Suspense fallback={<div> Loading Remote1App...</div>}>
      <Remote1AppWithLoadRemote {...props} ref={ref} />
      </Suspense>
    </ErrorBoundary>
));

export default function App() {
  return (
    <>
      <div className="flex flex-row">
        <h2>Remote1</h2>
        <Remote1AppWithErrorBoundary />
      </div>
    </>
  );
}

Effect as follows:

Block network request
Block then Enable