Introduction
Webpack 5 marked a watershed moment in the evolution of JavaScript bundling. After years of incremental improvements in the 4.x series, Webpack 5 introduced transformative features that fundamentally changed how we think about module sharing, asset processing, and build performance. The headline feature — Module Federation — enables multiple independently deployed applications to share code at runtime without requiring coordinated deployments or shared node_modules.
Before Module Federation, sharing code between applications typically meant publishing npm packages, using monorepo tooling, or maintaining shared CDN bundles that required careful version coordination. Module Federation eliminates all of these constraints by allowing each application to expose modules that other applications can consume dynamically, at runtime, with automatic dependency resolution. This is not code splitting — this is true micro-frontend architecture at the module level.
Alongside Module Federation, Webpack 5 introduced asset modules that replace the need for file-loader, url-loader, and raw-loader, persistent filesystem caching that slashes rebuild times from minutes to seconds, and improved tree-shaking that eliminates more dead code than ever before. In this comprehensive guide, we will explore every major Webpack 5 feature with production-ready code examples.
Understanding Webpack 5: Core Concepts
Module Federation Architecture
Module Federation allows a JavaScript application to dynamically load code from another application while sharing dependencies. The architecture consists of three key roles:
Host (Consumer): The application that loads remote modules at runtime. It declares which remotes it wants to consume and which shared dependencies it can provide.
Remote (Producer): The application that exposes modules for consumption. It declares an exposes map that names the modules it makes available.
Shared Dependencies: Libraries that both host and remote need (e.g., React, Vue). Module Federation automatically negotiates version compatibility and ensures only one copy of each shared dependency is loaded.
// Remote Application (producer)
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./Header': './src/components/Header',
'./utils': './src/utils/index',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};// Host Application (consumer)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};Asset Modules
Webpack 5 introduced four new module types that eliminate the need for external loaders:
asset/resource: Emits a separate file and exports the URL (replacesfile-loader)asset/inline:Exports a data URI (replacesurl-loader)asset/source: Exports the source content as a string (replacesraw-loader)asset: Automatically chooses betweenresourceandinlinebased on file size
module.exports = {
module: {
rules: [
{
test: /\.(png|jpg|gif|svg)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024, // 8KB - inline if smaller
},
},
},
{
test: /\.txt$/,
type: 'asset/source',
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
type: 'asset/resource',
},
],
},
};Persistent Caching
Webpack 5 introduced a filesystem cache that persists across builds, dramatically reducing rebuild times:
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename], // Invalidate cache when config changes
},
cacheDirectory: path.resolve(__dirname, '.webpack_cache'),
name: 'production-cache',
version: '1.0',
},
};Architecture and Design Patterns
The Shell Application Pattern
In micro-frontend architectures, a shell application serves as the orchestrator that loads remote micro-frontends on demand:
// Shell application config
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
dashboard: 'dashboard@https://dashboard.example.com/remoteEntry.js',
settings: 'settings@https://settings.example.com/remoteEntry.js',
analytics: 'analytics@https://analytics.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
'react-router-dom': { singleton: true },
'@tanstack/react-query': { singleton: true },
},
}),
],
};// Dynamic remote loading in shell
const RemoteApp = React.lazy(() => import('dashboard/App'));
function Shell() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard/*" element={<RemoteApp />} />
<Route path="/settings/*" element={<SettingsApp />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}Bidirectional Federation
Both applications can be hosts and remotes simultaneously, enabling truly modular architectures:
// Application A config
new ModuleFederationPlugin({
name: 'appA',
filename: 'remoteEntry.js',
remotes: { appB: 'appB@https://app-b.com/remoteEntry.js' },
exposes: { './UserProfile': './src/UserProfile' },
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
// Application B config
new ModuleFederationPlugin({
name: 'appB',
filename: 'remoteEntry.js',
remotes: { appA: 'appA@https://app-a.com/remoteEntry.js' },
exposes: { './Notifications': './src/Notifications' },
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})Dynamic Remote Containers
For scenarios where remote URLs are not known at build time, Webpack 5 supports dynamic remote loading:
async function loadRemote(remoteUrl, scope) {
// Fetch the remote entry script
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = remoteUrl;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
// Initialize the sharing scope
await __webpack_init_sharing__('default');
const container = window[scope];
await container.init(__webpack_share_scopes__.default);
return container;
}
async function loadRemoteModule(remoteUrl, scope, module) {
const container = await loadRemote(remoteUrl, scope);
const factory = await container.get(module);
return factory();
}Step-by-Step Implementation
Setting Up Module Federation from Scratch
Let's build a complete micro-frontend setup with two applications that share React and exchange components.
// apps/dashboard/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
entry: './src/index',
mode: 'development',
devServer: { port: 3001, hot: true },
output: { publicPath: 'http://localhost:3001/' },
resolve: { extensions: ['.tsx', '.ts', '.js'] },
module: {
rules: [
{ test: /\.tsx?$/, loader: 'ts-loader', exclude: /node_modules/ },
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{
test: /\.(png|svg|jpg)$/,
type: 'asset',
parser: { dataUrlCondition: { maxSize: 4096 } },
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'dashboard',
filename: 'remoteEntry.js',
exposes: {
'./DashboardApp': './src/App',
'./Widget': './src/components/Widget',
'./useMetrics': './src/hooks/useMetrics',
},
shared: {
react: { singleton: true, eager: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, eager: true, requiredVersion: '^18.0.0' },
},
}),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
};Configuring the Host Application
// apps/shell/webpack.config.js
module.exports = {
entry: './src/index',
mode: 'development',
devServer: { port: 3000, hot: true },
output: { publicPath: 'http://localhost:3000/' },
module: {
rules: [
{ test: /\.tsx?$/, loader: 'ts-loader', exclude: /node_modules/ },
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{
test: /\.(png|svg|jpg)$/,
type: 'asset',
parser: { dataUrlCondition: { maxSize: 4096 } },
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
dashboard: 'dashboard@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true, eager: true },
'react-dom': { singleton: true, eager: true },
},
}),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
};Consuming Remote Modules
// apps/shell/src/App.tsx
import React, { Suspense, lazy } from 'react';
const DashboardApp = lazy(() => import('dashboard/DashboardApp'));
const Widget = lazy(() => import('dashboard/Widget'));
export function App() {
return (
<div className="shell">
<nav>
<a href="/">Home</a>
<a href="/dashboard">Dashboard</a>
</nav>
<main>
<Suspense fallback={<div>Loading module...</div>}>
<DashboardApp />
<Widget title="Revenue" />
</Suspense>
</main>
</div>
);
}Type Safety for Remote Modules
// apps/shell/src/types/dashboard.d.ts
declare module 'dashboard/DashboardApp' {
const DashboardApp: React.ComponentType;
export default DashboardApp;
}
declare module 'dashboard/Widget' {
interface WidgetProps {
title: string;
refreshInterval?: number;
}
const Widget: React.ComponentType<WidgetProps>;
export default Widget;
}
declare module 'dashboard/useMetrics' {
interface Metrics {
revenue: number;
users: number;
conversion: number;
}
export function useMetrics(): { data: Metrics; isLoading: boolean; error: Error | null };
}Asset Modules Configuration
module.exports = {
module: {
rules: [
// Images: auto-choose inline or file based on size
{
test: /\.(png|jpe?g|gif|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: { maxSize: 10 * 1024 },
},
generator: {
filename: 'images/[name].[contenthash:8][ext]',
},
},
// SVGs as separate files for caching
{
test: /\.svg$/,
type: 'asset/resource',
generator: {
filename: 'icons/[name].[contenthash:8][ext]',
},
},
// Fonts always as separate files
{
test: /\.(woff2?|eot|ttf|otf)$/i,
type: 'asset/resource',
generator: {
filename: 'fonts/[name].[contenthash:8][ext]',
},
},
// Raw text files
{
test: /\.md$/,
type: 'asset/source',
},
],
},
};Real-World Use Cases and Case Studies
Use Case 1: Multi-Team E-Commerce Platform
A large e-commerce company splits its frontend into independently deployable micro-frontends: product catalog, shopping cart, checkout, and user account. Each team owns their micro-frontend and deploys independently. Module Federation allows the product catalog team to expose a ProductCard component that the shopping cart team consumes, while sharing React, the design system, and analytics utilities as singletons.
Use Case 2: Plugin-Based SaaS Dashboard
A SaaS analytics product uses Module Federation to load customer-specific plugins at runtime. The core dashboard exposes its state management and UI primitives, and each customer's custom visualizations are loaded as remote modules. New plugins are deployed without touching the core application, enabling a marketplace of community-built widgets.
Use Case 3: Gradual Migration from Legacy
A company migrating from Angular.js to React uses Module Federation to incrementally replace pages. The legacy application is wrapped as a remote that exposes its routing, and the new React shell progressively replaces routes. Shared dependencies like date utilities and API clients are federated to avoid duplication.
Use Case 4: White-Label Product Suite
A fintech company builds a white-label banking platform where each partner bank gets a customized frontend. The core banking UI components are exposed as remotes, and each partner's frontend consumes them while adding bank-specific branding and features. Asset modules handle bank-specific logos and themes efficiently.
Best Practices for Production
-
Singleton Shared Dependencies: Mark core libraries like React and React DOM as
singleton: trueto prevent duplicate instances that cause runtime errors. -
Version Range Constraints: Use
requiredVersionin shared configuration to prevent incompatible versions from being loaded. Semantic version ranges ensure compatibility. -
Eager Loading for Shell: Set
eager: trueon shared dependencies in the shell application to ensure they're available before any remote loads. -
Asset Module Size Thresholds: Set
dataUrlCondition.maxSizeto 4–10KB for images. Below this threshold, images are inlined as base64 to reduce HTTP requests; above it, they're emitted as separate files for caching. -
Persistent Cache Strategy: Configure filesystem caching with proper
buildDependenciesto invalidate cache when source code, configuration, or lock files change. -
Error Boundaries for Remotes: Always wrap remote module imports in error boundaries and loading states to handle network failures gracefully.
-
TypeScript Declarations: Maintain
.d.tsfiles for all remote modules to ensure type safety across federation boundaries. -
Versioned Remote Entry URLs: Include content hashes or version numbers in remote entry URLs to enable cache busting when remotes are updated.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Duplicate React instances | "Invalid hook call" errors at runtime | Use singleton: true and consistent requiredVersion across all federated apps |
| Stale remote entry cached | Users load outdated code after deployment | Version remoteEntry.js filenames or use cache-busting query parameters |
| Missing shared dependencies | Runtime errors when remote requires unshared library | Audit shared config across all federated applications |
| Large remote entry bundles | Slow initial load when consuming remote modules | Use dynamic imports within exposed modules; only expose what's needed |
| Asset paths break across origins | Images and fonts fail to load in federated modules | Use absolute publicPath or runtime path resolution |
| TypeScript errors for remote modules | No IntelliSense, compilation errors | Create .d.ts declaration files for all exposed modules |
Performance Optimization
Tree Shaking Improvements
Webpack 5's improved tree-shaking handles more patterns, including nested exports and CommonJS:
// webpack.config.js
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
sideEffects: true,
concatenateModules: true,
minimize: true,
minimizer: ['...', new CssMinimizerPlugin()],
splitChunks: {
chunks: 'all',
maxInitialRequests: 25,
minSize: 20000,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name(module) {
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)[1];
return `vendor.${packageName.replace('@', '')}`;
},
},
},
},
},
};Module Federation Performance Tuning
new ModuleFederationPlugin({
name: 'host',
remotes: {
// Use import() syntax for lazy-loaded remotes
heavyApp: `promise new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.example.com/remoteEntry.js';
script.onload = () => {
const proxy = { get: (request) => window.heavyApp.get(request) };
resolve(proxy);
};
script.onerror = reject;
document.head.appendChild(script);
})`,
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
eager: process.env.NODE_ENV === 'development',
},
},
})Comparison with Alternatives
| Feature | Webpack 5 Module Federation | npm Packages | Single-SPA | iframe |
|---|---|---|---|---|
| Runtime sharing | Yes (automatic) | No (build-time) | Yes (manual) | No |
| Dependency deduplication | Automatic | Manual via externals | Manual | Complete isolation |
| Independent deployment | Yes | No | Yes | Yes |
| Shared state | Easy (singletons) | N/A | Via custom events | PostMessage only |
| Performance overhead | Minimal | None | Moderate | High (full page load) |
| Developer experience | Excellent | Good | Moderate | Poor |
| TypeScript support | Manual declarations | Full | Partial | N/A |
| CSS isolation | Manual (CSS Modules) | Native | Manual | Native |
Advanced Patterns and Techniques
Dynamic Federation with API Gateway
class FederationRegistry {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.cache = new Map();
}
async register(name) {
if (this.cache.has(name)) return this.cache.get(name);
const manifest = await fetch(`${this.baseUrl}/${name}/manifest.json`).then(r => r.json());
const container = await this.loadContainer(manifest.remoteEntry);
await container.init(__webpack_share_scopes__.default);
this.cache.set(name, container);
return container;
}
async loadContainer(url) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
return window[name];
}
}Cross-Framework Federation
// Expose a Vue component via Module Federation
// that can be consumed by a React host
new ModuleFederationPlugin({
name: 'vueRemote',
filename: 'remoteEntry.js',
exposes: {
'./VueWidget': './src/wrappers/VueWidgetWrapper',
},
})
// Wrapper that renders Vue inside React
import { createApp, h } from 'vue';
import MyVueComponent from '../components/MyVueComponent.vue';
export default function VueWidget({ containerId, props }) {
React.useEffect(() => {
const app = createApp(MyVueComponent, props);
app.mount(`#${containerId}`);
return () => app.unmount();
}, [props]);
return React.createElement('div', { id: containerId });
}Testing Strategies
// Integration test for Module Federation
describe('Module Federation Integration', () => {
it('loads remote module successfully', async () => {
const remoteModule = await import('remoteApp/Button');
expect(remoteModule.default).toBeDefined();
expect(typeof remoteModule.default).toBe('function');
});
it('shares dependency instances', async () => {
const hostReact = require('react');
const remoteModule = await import('remoteApp/Component');
// Both should reference the same React instance
expect(remoteModule.__REACT_VERSION__).toBe(hostReact.version);
});
it('handles remote loading failures', async () => {
const { getByTestId } = render(<App />);
// Simulate network failure for remote
server.use(rest.get('/remoteEntry.js', (req, res, ctx) => res(ctx.status(503))));
// Should show error boundary, not crash
await waitFor(() => expect(getByTestId('error-fallback')).toBeInTheDocument());
});
});Future Outlook
Module Federation has evolved into a standalone project (@module-federation/runtime) that works beyond Webpack, supporting Vite, Rspack, and other bundlers. The upcoming Module Federation 2.0 introduces enhanced type safety, runtime version negotiation, and improved dev tools. As micro-frontend architectures continue to gain adoption, Module Federation is positioned as the de facto standard for runtime module sharing in the JavaScript ecosystem.
Conclusion
Webpack 5's Module Federation and asset modules represent a paradigm shift in how JavaScript applications are built, shared, and deployed. Module Federation enables true micro-frontend architecture with automatic dependency resolution, while asset modules simplify the handling of static resources without external loaders.
Key takeaways:
- Module Federation enables runtime code sharing between independently deployed applications without coordinated releases.
- Shared dependencies must be configured carefully — use singleton mode, version ranges, and eager loading for critical libraries.
- Asset modules eliminate legacy loaders — use
asset/resource,asset/inline,asset/source, andassetwith size thresholds. - Persistent filesystem caching reduces rebuild times from minutes to seconds.
- TypeScript declarations for remote modules are essential for type safety across federation boundaries.