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.
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:
- User loads host app, which lazy-loads
checkoutremote - Checkout remote renders, creates React components with state, effects, refs
- Remote team deploys a new version, changing
remoteEntry.js - User navigates away from checkout, then back—host fetches new remoteEntry
- 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)" entriesCompare 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 singletonsSolution 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
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement