Micro-Frontends with Webpack 5 Module Federation

Photo by AltumCode on Unsplash

The pain of the “monster-lith”…

Most of us at some point in our careers have likely dealt with a single large application that houses most (if not all) of our product’s production code. While most products typically start off in this monolith-fashion, maintaining a single project as your product scales brings a fleet of issues. Shipping new features and bug fixes in the midst of a large team where dozens of other developers are also fighting to merge in can lead to pull requests taking hours (sometimes days) to finalize. Not to mention thinking your PR is next up only to find out you now have a breaking merge-conflict with master that causes you to rebase and to re-trigger the everlasting cycle all over again.

Aside from a poor developer experience and more friction to rapidly shipping new features, single-repo apps also pose many customer-facing problems. For instance, a single problematic merge can take down the entire app and render it fully unresponsive to your entire customer base (like the time that guy brought down AWS…). Breaking a product into multiple, smaller services not only breaks down the single point of failure problem, but significantly enhances the developer experience and allows teams to iterate and operate independently.

The World of Microservices

This microservice principle allows products or feature sets within an application to operate independently from one another. This is becoming increasingly more popular, especially in the realm of backend repositories and services. Typically, multiple services have a single orchestrator which is responsible for routing requests, but each service is able to be shipped and maintained entirely on its own. If something within a single service crashes, only that specific domain of the product is affected rather than the entire app. If done correctly, this can help lead to improved performance, uptime, and overall developer satisfaction. But how does this orchestration of services and applications work with a frontend application?

The Single SPA

In today’s world filled with optimized Javascript libraries and frameworks, single-page-applications are known for being performant, specifically in terms of route changes. With libraries like React, it’s typically encouraged to keep your entire frontend within the same application. This allows the browser to keep the bundled app at its beck and call and render the route change almost immediately, rather than going through the lifecycle of requesting an entirely new bundle from a separate server. While this is great from a performance standpoint, particularly for end users, it’s very difficult to maintain at scale. Attempting to maintain proper patterns, shared components, and features while reducing duplicated code and overall app bundle size when working on a team of 50+ engineers is near impossible. Sure, there are things like lint rules that can control how and where things are added, but there can only be so much control put in place before causing significant friction to each team trying to release something.

Enter Module Federation

While there are many implementations that aim to resolve this, the hopeful solution is a new spec released with Webpack 5 — Module Federation. This allows Javascript apps to dynamically run code from other bundles or builds, while also allowing individual builds to share code and resources. The magic lies within sharing dependencies and common bundled code between individual builds or repos. Let’s look at an example.

Let’s say we have an application that is made up of 2 separate microservices or repositories — a “home” page and an “about” page. Each of these pages are completely independent applications and repositories which can be maintained and deployed separately. In order to maintain the performance benefits mentioned above, module federation works by assigning a temporary host app and loading its bundle on page load, as typically happens with traditional SPAs. However, when changing routes and rather than fetching and loading an entirely separate bundle to support the new build, webpack only fetches and installs the missing dependencies. This leads to a load of literal kilobytes as opposed to potentially bytes of data to support the incoming bundle. Think of it like React’s virtual DOM, where only updates are propagated to the DOM rather than rewriting the whole thing. This allows for route changes to appear without full page reloads.

How does it work?

Let’s walk through the lifecycle to better understand what’s happening. On initial page load, whichever resource that’s being requested is assigned as the host. This is just a Webpack build that is initialized first during a page load (when the onLoad event is triggered). If our “home” page is the host, when navigating to the “about” page (the remote) the host dynamically imports a module from the remote application (the about page spa). Again, it doesn’t load the main entry point and the entire other application — only a few kilobytes of code. When the page is refreshed again, a new host is assigned and the lifecycle continues.

A real-life example

The best way to learn is by doing, so let’s jump in to some code. We’ll be using React, but the concept can apply to other JS frameworks and libraries. We’ll start by outlining our application and microservice architecture.

container (orchestrator)
about_page
design_system

We’ll have a single “umbrella” app that will work as our main orchestrator or container. This app can contain any common or shared modules and dependencies, such as a router and navigation system. We’ll also have our 2 isolated SPAs, an about page, which will act as our separate product, and a design system which will contain commonly used components. Each of these repositories are completely separate and can be updated and shipped on their own.

If you want to skip ahead and look at the code, you can find the individual repos here:

The Rundown

Here are the basic steps needed to enable each of these applications to co-mingle:

  1. Import the ModuleFederationPlugininto the webpack script and setup exposed and remote components and dependencies
  2. Add the entry point file refs to the head of your application
  3. Reconfigure index.js to use a bootstrap.js file
  4. Import remote components using React.lazy

It’s pretty simple! Let’s start by looking at the setup for our container app. Our simplified ending file structure will look as follows:

src
App.js
routes.js
Navigation.js
Home.js
index.js
index.html
bootstrap.js
package.json
webpack.config.json
//others remitted for instructional purposes

Let’s start with our simple webpack config:

const HtmlWebPackPlugin = require('html-webpack-plugin');
const path = require('path');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const htmlPlugin = new HtmlWebPackPlugin({
template: './src/index.html',
filename: './index.html'
});
module.exports = {
mode: 'development',
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3000
},
output: {
publicPath: 'http://localhost:3000/'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'federation_demo_container',
library: { type: 'var', name: 'federation_demo_container' },
filename: 'remoteEntry.js',
exposes: {
'./Home': './src/Home'
},
shared: ['react', 'react-dom']
}),
htmlPlugin
]
};

Most of this is pretty boilerplate, but notice the plugin we’ve added:

new ModuleFederationPlugin({
name: 'federation_demo_container',
library: { type: 'var', name: 'federation_demo_container' },
filename: 'remoteEntry.js',
exposes: {
'./Home': './src/Home'
},
shared: ['react', 'react-dom']
})

Let’s break this down:

  • name and library— these are arbitrary values we’ll assign to our application for use when importing its resources in other applications
  • filename — the named bundle entry point file into our application. This is the main bulk of where our build will land for consumer apps, notice the ending bundle size (31kb)
courtesy of giphy
  • exposes — this is where we set any components or files we want to expose to our remote applications. In this case we’ve exported our Home page
  • shared — here we specify and shared node modules or dependencies. For now we’ll just use react and react-dom

We’ll also need to reconfigure the main structure of our app using the bootstrap.js file. You’ll need to move everything from index.js to bootstrap.js

//bootstrap.jsimport React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));

And then replace the contents of index.js with

import('./bootstrap');

This is required by the webpack plugin. The rest of the application is pretty basic and I won’t be sharing its contents for now for the sake of brevity, but feel free to browse the repos on Github. For now just note we have a basic routing and navigation system that renders a single landing page.

Adding a route

Now it’s time to introduce our second application — the about page (this will run on port 3002). We’ll start with our same boilerplate webpack, but we’ll update the plugin config to accommodate our needs:

//webpack.config.jsonnew ModuleFederationPlugin({
name: 'federation_demo_about',
library: { type: 'var', name: 'federation_demo_about' },
filename: 'remoteEntry.js',
remotes: {
federation_demo_container: 'federation_demo_container'
},
exposes: {
'./routes': './src/routes'
},
shared: ['react', 'react-dom']
})

Very similar to what we had above, but now we’ve added remotes. This designates additional resources to consume within our application. We’ve also updated our exposed components to export a routes file. We’ll come back to this in a minute.

The next thing we need to do is add the main entry point for our remote host (the container). Update index.html to include the following:

//index.html<html>
<head>
<title>Federation Demo - About</title>
<script src="http://localhost:3000/remoteEntry.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

Notice we’re referencing the server of our container app, and more specifically the remoteEntry file we’ve micro-bundled.

Now we need to setup our routes and components. We added a basic About.js page, but need to allow for our expose to export the route, so we’ll create a routes.js file:

//routes.jsimport React from 'react';const About = React.lazy(() => import('./About'));const routes = [
{
path: '/about',
component: About
}
];
export default routes;

We’re now lined up to import this route in our container app.

Back to the container application, we’ll now need to add the remote host for our about page. Let’s update our plugin to include the new remote:

//webpack.config.jsonnew ModuleFederationPlugin({
name: 'federation_demo_container',
library: { type: 'var', name: 'federation_demo_container' },
filename: 'remoteEntry.js',
remotes: {
federation_demo_about: 'federation_demo_about'
},

exposes: {
'./Home': './src/Home'
},
shared: ['react', 'react-dom']
})

We add the remote as the name field in our about app (federation_demo_about). Next we need to add the script to our index.html

<script src="http://localhost:3002/remoteEntry.js"></script>

Now we can dive into our routes and see how to make use of the shared resource. Here’s a look at App.js :

import React, { lazy, Suspense } from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom';
import Navigation from './Navigation';
import localRoutes from './routes';
import remoteRoutes from 'federation_demo_about/routes';
const routes = [...localRoutes, ...remoteRoutes];const App = () => (
<HashRouter>
<div>
<Navigation />
<Suspense fallback={<div>Loading...</div>}>
<Switch>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
component={route.component}
exact={route.exact}
/>
))}
</Switch>
</Suspense>
</div>
</HashRouter>
);
export default App;

You’ll see how we import our remote routes (what we exported) from the about application, and then combine them with our local routes. We’ll want to wrap our routes in React.Suspense so we can set default fallbacks and loading states.

That should do it! Let’s start up our servers and see what happens. Make sure to run yarn build to create the dist/ folder which contains our bundles in each repository. Start up each server with yarn start and navigate to http://localhost:3000. You should see our navbar and be able to navigate between the routes

The Home page
The About page

Notice how we don’t need a full page reload when navigating. More importantly, look at the amount of resources loaded — a whopping 2.2kb!

Introducing a third package — the design system

What if we have a shared UI component library that we want all our applications to make use of? This is an extremely common case nowadays, especially with larger-scale apps. We’ve created our third repo following similar suit to the previous ones. Here’s our plugin config this time around:

new ModuleFederationPlugin({
name: 'federation_demo_design',
library: { type: 'var', name: 'federation_demo_design' },
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button'
},
shared: ['react', 'react-dom']
})

Since this application only exports its contents and does not consume anything, we can exclude any remotes. We’ll just create a standard Button component for now that we can load into our other applications. For the sake of documentation, this app will run on port 3001.

Now we’ll add the remote to our other bundles, simply by adding

federation_demo_design: 'federation_demo_design'

to our remotes list in our webpack config. We also need to add the entry point in index.html

<script src="http://localhost:3001/remoteEntry.js"></script>

We can now make use of this component as follows:

//Home.jsimport React, { lazy, Suspense } from 'react';const Button = lazy(() => import('federation_demo_design/Button'));const Home = () => (
<div style={{ padding: '16px' }}>
<h1 style={{ color: '#264653' }}>Home Page</h1>
<Suspense fallback="Loading Button...">
<Button>Click me on home!</Button>
</Suspense>

</div>
);
export default Home;

We lazy load the button component and wrap it in a suspense, allowing us to show a temporary loading state if needed. We can add this to all of our remote app consumers, which gives us the shared button!

Home Page
About page

Now try changing the button styles in the design system. Reloading the consumer apps automatically propagates the new changes! 😍

Closing

Although this is a much simplified example using only local applications, you can get the idea of the power and benefit Modular Federation can add to your product. Micro-frontends are sure to be a thing of the future — allowing companies to rapidly scale individualized products and ship features independently, as is being done with many backend services across the world. In case you missed it, you can find the links to each of the apps used in the example below:

Here’s some extra reading in case you’re interested in digging in more

Thanks for reading!

About Brian

I’m a Utah-based software engineer, currently working for Podium — where we build awesome tools to allow businesses to interact with their customers over text. I’m an avid fan of food and love to cook. I also enjoy playing hockey and snowboarding and hanging out with my 3 crazy children. Find me online at any of the places below!

Disclaimer: I am not an expert in this area. My writing of this article is mainly for my own understanding and conceptual grasping. If I missed something, please let me know in the comments!

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store