EdgeCases Logo
Feb 2026
Build Tools
Deep
7 min read

Module Federation Memory Leaks: The Shared Singleton Problem

Micro-frontends sharing React create subtle memory leaks when remotes update—old module closures retain references to shared singletons, preventing garbage collection.

webpack
module-federation
micro-frontends
memory-leaks
react
performance
architecture

Module Federation lets micro-frontends share dependencies like React. Configure singleton: true, and both host and remote use the same React instance. But when a remote module updates independently, the old version's React context, event handlers, and component instances can linger—creating memory leaks that grow with each deployment.

How Shared Dependencies Work

// Host webpack config
new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
});

// Remote webpack config
new ModuleFederationPlugin({
  name: 'checkout',
  exposes: {
    './CheckoutForm': './src/CheckoutForm',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
});

The host loads first and provides React. When checkout loads, it reuses the host's React instead of bundling its own. This saves ~40KB gzipped per remote.

The Memory Leak Pattern

Here's the scenario that causes leaks:

  1. User loads host app, which lazy-loads checkout remote
  2. Checkout remote renders, creates React components with state, effects, refs
  3. Remote team deploys a new version, changing remoteEntry.js
  4. User navigates away from checkout, then back—host fetches new remoteEntry
  5. Old module's closures still hold references to shared React
// Inside the old remote module (still in memory)
function CheckoutForm() {
  const [cart, setCart] = useState([]); // Closure over old React
  
  useEffect(() => {
    // This effect cleanup may never run properly
    const subscription = cartService.subscribe(setCart);
    return () => subscription.unsubscribe();
  }, []);
  
  return <form>...</form>;
}

The old CheckoutForm module isn't garbage collected because:

  • Webpack's module cache holds a reference to the old module
  • The old module's closures reference the shared React instance
  • React's internals may still reference the old component's fiber tree

Measuring the Leak

Use Chrome DevTools Memory tab to observe:

// Take heap snapshot before loading remote
// Navigate to checkout (loads remote)
// Navigate away
// Force GC (click trash icon in Memory tab)
// Take second snapshot

// In second snapshot, look for:
// - Detached HTMLDivElement nodes
// - Retained objects containing "Checkout" or your component names
// - Growing number of "(closure)" entries

Compare heap sizes across multiple remote load/unload cycles. A growing baseline indicates retained objects.

Why Cleanup Doesn't Run

The remote module isn't "unmounted" in any React sense—it's just no longer rendered. But the module itself persists in Webpack's cache:

// Webpack's internal module cache
__webpack_modules__ = {
  'checkout/CheckoutForm': [Function], // Old module still here
  'checkout/CheckoutForm?v2': [Function], // New module added
};

// Old module's factory function holds closures over:
// - The shared React instance
// - Any module-level variables
// - Imported service singletons

Solution 1: Explicit Module Cleanup

Implement a cleanup protocol for your remotes:

// Remote exposes a cleanup function
// checkout/src/cleanup.ts
const cleanupCallbacks: Array<() => void> = [];

export function registerCleanup(callback: () => void) {
  cleanupCallbacks.push(callback);
}

export function cleanup() {
  cleanupCallbacks.forEach((cb) => cb());
  cleanupCallbacks.length = 0;
}

// Components register their cleanup
function CheckoutForm() {
  useEffect(() => {
    const unsubscribe = cartService.subscribe(handleUpdate);
    registerCleanup(unsubscribe);
    return unsubscribe;
  }, []);
}

// Host calls cleanup before loading new version
import('checkout/cleanup').then(({ cleanup }) => cleanup());

Solution 2: Version-Aware Loading

Include version in the remote URL to bust caches properly:

// Host fetches version manifest
async function loadCheckout() {
  const manifest = await fetch('https://checkout.example.com/manifest.json')
    .then((r) => r.json());
  
  const remoteUrl = `https://checkout.example.com/remoteEntry.${manifest.hash}.js`;
  
  // Dynamically add remote
  await __webpack_init_sharing__('default');
  const container = await import(/* webpackIgnore: true */ remoteUrl);
  await container.init(__webpack_share_scopes__.default);
  
  return container.get('./CheckoutForm');
}

New versions get different URLs, but this alone doesn't clean up old modules.

Solution 3: Module Eviction

Manually evict old modules from Webpack's cache (advanced):

// ⚠️ Internal API - may break between Webpack versions
function evictModule(moduleName: string) {
  const moduleId = Object.keys(__webpack_modules__).find((id) =>
    id.includes(moduleName)
  );
  
  if (moduleId && __webpack_modules__[moduleId]) {
    // Clear the module from cache
    delete __webpack_modules__[moduleId];
    delete __webpack_module_cache__[moduleId];
    
    // Also clear from share scope
    const scope = __webpack_share_scopes__.default;
    Object.keys(scope).forEach((pkg) => {
      Object.keys(scope[pkg]).forEach((version) => {
        if (scope[pkg][version].from === moduleName) {
          delete scope[pkg][version];
        }
      });
    });
  }
}

// Before loading new version
evictModule('checkout');

Solution 4: Isolated React Instances

For truly independent remotes, don't share React at all:

// Remote webpack config - DON'T share React
new ModuleFederationPlugin({
  name: 'checkout',
  exposes: {
    './CheckoutForm': './src/CheckoutForm',
  },
  shared: {
    // Intentionally NOT sharing React
    // Each remote bundles its own
  },
});

Trade-offs:

  • Larger bundle size (~40KB gzipped per remote)
  • Can't share context between host and remote
  • No React state persistence across remote updates
  • But: clean garbage collection when remote unmounts

The Singleton Trap

singleton: true is often applied blindly to all shared dependencies. Be selective:

shared: {
  // ✅ Must be singleton - multiple React instances break hooks
  react: { singleton: true, strictVersion: true },
  'react-dom': { singleton: true, strictVersion: true },
  
  // ⚠️ Probably should be singleton - context providers
  'react-query': { singleton: true },
  'styled-components': { singleton: true },
  
  // ❌ Don't need singleton - stateless utilities
  lodash: { singleton: false }, // Each remote can have own version
  'date-fns': { singleton: false },
}

Monitoring in Production

Track memory growth over time:

// Report memory usage periodically
if (performance.memory) {
  setInterval(() => {
    const { usedJSHeapSize, totalJSHeapSize } = performance.memory;
    
    analytics.track('memory_usage', {
      usedMB: Math.round(usedJSHeapSize / 1024 / 1024),
      totalMB: Math.round(totalJSHeapSize / 1024 / 1024),
      pathname: location.pathname, // Which remote is loaded
    });
  }, 60000);
}

Alert on sustained growth patterns that correlate with remote module loads.

Key Takeaways

  • Shared singletons persist in memory even when remotes are "unloaded"
  • Old module closures hold references to shared dependencies, preventing GC
  • Implement explicit cleanup protocols for stateful remotes
  • Consider not sharing stateless utilities—the bundle cost may be worth clean GC
  • Monitor heap growth in production to catch leaks early

Advertisement

Related Insights

Explore related edge cases and patterns

CSS
Surface
CSS Anchor Positioning: Tooltips Without JavaScript
6 min
Browser APIs
Deep
ResizeObserver: The 'Loop Limit Exceeded' Error
6 min
React
Deep
useEffectEvent: Solving Stale Closures Forever
5 min

Advertisement