The Monolith Bottleneck
Scaling a frontend application often feels like trying to steer a massive cargo ship through a narrow canal. In my early days working on enterprise React projects, every feature lived in a single, bloated repository.
It worked for a while. However, once our team expanded from five developers to fifty, the friction became physical. Build times crept from two minutes to over twenty, merge conflicts turned into daily stand-off sessions, and a tiny CSS bug in the footer could unexpectedly crash the entire checkout flow.
Monolithic architectures eventually hit a ceiling. While we often try to solve this by splitting code into internal NPM packages, that approach has a major flaw: any update requires a full rebuild and redeployment of the host application. We needed a way to deploy UI features independently without risking the stability of the whole system. Micro-frontends provided that escape hatch.
The Shift to Module Federation
Micro-frontends break a massive web app into smaller, self-contained pieces that appear as a single product to the end user. While developers have tried using iframes or server-side composition for years, Webpack 5 introduced a more elegant solution called Module Federation.
This technology allows a JavaScript application to dynamically load code from a different build at runtime. It moves away from traditional bundling, where every dependency must be present at build time. Instead, you can share components, hooks, or entire pages across different servers. In production environments, I have seen this architecture allow teams to deploy 10 times a day without once needing to coordinate with other departments.
To master this, you need to understand three core roles:
- Host: The shell application that acts as the entry point and “consumes” remote pieces.
- Remote: An independent application that “exposes” specific components or logic.
- Shared: Common dependencies, like React or Material UI, that are loaded once and shared across the ecosystem to save bandwidth.
Building Your First Micro-frontend Architecture
Enough theory—let’s look at the implementation. We will build two apps: a Remote (a Product Card) and a Host (the main Dashboard).
Step 1: Setting up the Remote App
First, create a directory for your remote application and initialize it. We will use Webpack 5 to handle the heavy lifting.
mkdir remote-app && cd remote-app
npm init -y
npm install react react-dom webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/preset-react --save-dev
Next, create a simple component in src/Button.js:
import React from 'react';
const RemoteButton = () => (
<button style={{ padding: '12px 24px', background: '#0070f3', color: 'white', border: 'none', borderRadius: '5px' }}>
Live Remote Button
</button>
);
export default RemoteButton;
The webpack.config.js is where the configuration happens. This file tells Webpack which components to make public:
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
mode: 'development',
devServer: { port: 3001 },
module: {
rules: [
{ test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } }
]
},
plugins: [
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
};
Step 2: Connecting the Host App
The Host application will pull the Button from the Remote. Set up a separate folder called host-app using port 3000. In the Host’s configuration, you must point to the Remote’s URL:
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
// ... standard config
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
};
Step 3: Runtime Integration
In your Host app, import the remote component using React’s lazy and Suspense. Since the code is fetched over the network, you need a loading state to handle the latency.
import React, { Suspense } from 'react';
const RemoteButton = React.lazy(() => import('remoteApp/Button'));
const App = () => (
<div style={{ fontFamily: 'sans-serif', padding: '20px' }}>
<h1>Enterprise Dashboard (Host)</h1>
<Suspense fallback="Fetching remote component...">
<RemoteButton />
</Suspense>
</div>
);
export default App;
Managing Shared State and Dependencies
Version mismatches are the most common pitfall in federated environments. If your Host runs React 18 but a Remote tries to force-load React 17, the application will likely crash. Using the singleton: true flag is non-negotiable here. It ensures Webpack only loads one instance of a library, even if multiple apps request it.
When it comes to state, keep it simple. I recommend managing global state at the Host level and passing data via props. If your micro-frontends need to talk to each other without being tightly coupled, use a lightweight custom event bus or a library like mitt. Do not share a single, massive Redux store across applications. Doing so creates a hidden dependency that defeats the purpose of being “independent.”
Final Thoughts
Adopting Module Federation is a strategic move, not just a technical one. It introduces complexity to your CI/CD pipeline and requires your team to have a deep grasp of Webpack internals. However, for large organizations, the trade-off is worth it. You gain faster builds and the ability for teams to own their release cycles.
Start small. Migrate a low-risk utility or a single navigation component first. Once you experience a deployment where you only have to build 5% of your codebase to ship a feature, you will never want to go back to the monolith.

