Shared Dependency Isolation: Multiple Share Scopes

In Module Federation, shared dependencies are registered into the default Share Scope by default. A single Scope is often not enough when:

  • You want to isolate part of your shared dependencies from the default pool (for example, running two React ecosystems side-by-side, gradual upgrades, or domain isolation in micro-frontends).
  • You want the same package to use different versions or strategies in different domains, while still being shared within each domain (singleton/reuse still works within a domain).

The key idea of multiple Share Scopes is: move shared dependency registration and resolution into different namespaces (Scopes), so you can isolate shared pools and layer policies.

Configuration Quick Map

The simplest way to understand multiple Share Scopes is to focus on what you configure on the producer, the consumer, and each shared entry. You don't need to learn runtime internal data structures or variable names.

  • Producer: use shareScope to declare which Share Scopes this provider initializes (default: default, supports string | string[]).
  • Consumer: use remotes[remote].shareScope to declare which Share Scopes the consumer aligns with a given provider (default: default).
  • Shared entry: use shared[pkg].shareScope to decide which pool a dependency is registered / resolved in (see shared.shareScope).

What Happens for Different Combinations

When the consumer initializes a provider, it first aligns the Share Scopes based on both sides' shareScope settings — so the provider knows which shared pools to reuse — then the provider initializes shared dependencies according to its own shareScope.

To make the alignment and initialization relationship easier to describe, we use:

  • HostShareScope for remotes[remote].shareScope configured on the consumer side
  • RemoteShareScope for shareScope configured on the provider side
Note

Do not configure shareScope / remotes[remote].shareScope as ['default'] or []:

  • Single Scope: use a string, not an array. The two follow different internal branches for share-pool alignment / initialization. If the consumer uses an array and the provider uses a string, the provider aligns Scopes using the consumer's list; if the provider uses an array, it only processes the provider's list.
  • Empty array []: results in no Scopes being initialized (no default, no alignment) — this is a misconfiguration.
HostShareScopeRemoteShareScopeShare Pool Behavior
'default''default'default is fully shared.
['default','scope1']'default'Only default is shared; scope1 is not initialized by the provider (the provider must also be configured with multiple Share Scopes).
'default'['default','scope1']default is shared; the consumer does not provide scope1 (it becomes {}), so deps under it cannot be reused from the consumer and fall back to local deps.
['scope1','default']['scope1','scope2']scope1 is shared; the consumer does not provide scope2 (it becomes {}), so deps under it cannot be reused from the consumer.
Rule of thumb

Share pools are provided by the consumer and initialized by the provider. Scopes the consumer does not list are filled in as {} (so missing scope names never crash); Scopes the provider does not list are not initialized. Both sides must list a Scope before a shared dep can really be reused under it.

Playground

Build Plugin Configuration

Producer

remote/rspack.config.ts
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app_remote',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/Button',
      },
      shareScope: ['default', 'scope1'],
      shared: {
        react: {
          singleton: true,
          requiredVersion: false,
          shareScope: 'default',
        },
        'react-dom': {
          singleton: true,
          requiredVersion: false,
          shareScope: 'default',
        },
        '@company/design-system': {
          singleton: true,
          requiredVersion: false,
          shareScope: 'scope1',
        },
      },
    }),
  ],
};

Key points:

  • shareScope: ['default','scope1'] controls which Scopes the provider's remoteEntry initializes at runtime.
  • shared[pkg].shareScope decides which Scope a dependency registers / resolves under. If @company/design-system is in scope1, it only participates in version selection and reuse within the scope1 pool.

Consumer

host/rspack.config.ts
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app_host',
      remotes: {
        app_remote: {
          external: 'app_remote@http://localhost:2001/remoteEntry.js',
          shareScope: ['default', 'scope1'],
        },
      },
    }),
  ],
};

Key points:

  • remotes[remote].shareScope controls which Scopes the consumer aligns when initializing a provider — they are passed to the provider as shareScopeKeys.
  • If the consumer is configured with multiple Scopes but the provider is single-Scope, the scopeMap is aligned but the provider only initializes sharing for its single Scope (see the combination table above). For multi-pool reuse to "really work", both consumer and provider usually need to agree on the same Scopes.

Pure Runtime (Runtime API)

If you do not declare providers and shared dependencies through the build plugin (for example, you want to register them dynamically at runtime), you can use the Runtime API for the same multi-Share-Scope effect. Two key APIs:

  • Register providers: registerRemotes or createInstance({ remotes }), declaring the Share Scopes to align via shareScope: string | string[] in each provider config.
  • Register shared dependencies: registerShared or createInstance({ shared }), deciding which Share Scope a dependency lands in via scope: string | string[] in each entry.
Field-name difference

The field name when registering shared dependencies is scope, not shareScope (different from the build plugin's shared[pkg].shareScope).

host/runtime.ts
import React from 'react';
import { registerRemotes, registerShared } from '@module-federation/enhanced/runtime';

registerRemotes([
  {
    name: 'app_remote',
    alias: 'remote',
    entry: 'http://localhost:2001/mf-manifest.json',
    shareScope: ['default', 'scope1'],
  },
]);

registerShared({
  react: {
    version: '18.0.0',
    scope: 'default',
    lib: () => React,
    shareConfig: {
      singleton: true,
      requiredVersion: '^18.0.0',
    },
  },
  '@company/design-system': {
    version: '1.2.3',
    scope: 'scope1',
    lib: () => require('@company/design-system'),
    shareConfig: {
      singleton: true,
      requiredVersion: false,
    },
  },
});

Fine-grained Control with Runtime Hooks

Multiple Share Scopes essentially group shared pools by name. If you need finer control over Scope selection, alignment, and fallback strategies, you can use Runtime Hooks to intervene during the init phase or shared resolution.

1. Rewrite shareScopeKeys per provider (beforeInitContainer)

The example below forces legacy_remote to always use the legacy Scope (even if a different shareScope was set at build time or runtime registration):

multi-scope-policy-plugin.ts
import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime';

export function multiScopePolicyPlugin(): ModuleFederationRuntimePlugin {
  return {
    name: 'multi-scope-policy',
    async beforeInitContainer(args) {
      if (args.remoteInfo.name !== 'legacy_remote') return args;

      const hostShareScopeMap = args.origin.shareScopeMap;
      if (!hostShareScopeMap.legacy) hostShareScopeMap.legacy = {};

      args.remoteEntryInitOptions.shareScopeKeys = ['legacy'];

      return {
        ...args,
        shareScope: hostShareScopeMap.legacy,
      };
    },
  };
}

2. Alias / fallback when a Scope is missing (initContainerShareScopeMap / resolveShare)

  • initContainerShareScopeMap: adjust each Scope's shareScope mapping during the provider's share-pool initialization.
  • resolveShare: override the final selection result by replacing args.resolver. Returning { ...args, scope: 'default' } alone is not enough in the current runtime implementation.

Example: if a package is not found in scope1, fall back to the default Scope:

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

export function scopeFallbackPlugin(): ModuleFederationRuntimePlugin {
  return {
    name: 'scope-fallback',
    resolveShare(args) {
      const current =
        args.shareScopeMap[args.scope]?.[args.pkgName]?.[args.version];
      if (current) return args;

      args.resolver = () => {
        const fallbackVersionMap = args.shareScopeMap.default?.[args.pkgName];
        if (!fallbackVersionMap) {
          return undefined;
        }

        const fallbackShared =
          fallbackVersionMap[args.version] ??
          Object.values(fallbackVersionMap)[0];

        if (!fallbackShared) {
          return undefined;
        }

        return {
          shared: fallbackShared,
          useTreesShaking: false,
        };
      };

      return args;
    },
  };
}

You can also alias one Scope to another in initContainerShareScopeMap (so two Scopes share the same pool object):

scope-alias-plugin.ts
import type { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime';

export function scopeAliasPlugin(): ModuleFederationRuntimePlugin {
  return {
    name: 'scope-alias',
    initContainerShareScopeMap(args) {
      if (args.scopeName !== 'scope1') return args;
      if (!args.hostShareScopeMap?.default) return args;

      args.hostShareScopeMap.scope1 = args.hostShareScopeMap.default;
      return {
        ...args,
        shareScope: args.hostShareScopeMap.default,
      };
    },
  };
}