Josh Burgess
Posted on November 18, 2024
Image from Micro Frontends post on martinfowler.com by Cam Jackson
Architecting Distributed Systems
Micro-Frontends with Module Federation
Creating a user interface that provides an exceptional user experience is challenging. You want your product to be memorable for the right reasons, but as your team and codebase grow, maintaining that positive impression can become difficult. Developers may inadvertently interfere with each other’s work, client-side state management can be overused — leading to performance issues and crashes on smaller devices — and overall developer experience can decline, causing team morale to drop.
This scenario is common, largely due to the tools many teams rely on being treated as “Golden Hammers” — where every problem starts to look like a nail. One such tool many teams have grown accustomed to is the Single Page Application (SPA) libraries and frameworks.
Single Page Application (SPA)
A Single Page Application (SPA) is a way to serve one index.html, along with CSS and JavaScript files, to a browser (the client). It provides the full functionality of an application—handling form submissions, data changes, and URL navigation—without needing to request additional HTML or JavaScript files from the server. This is where the power of JavaScript and the virtual Document Object Model (DOM) comes into play. The virtual DOM enables fast rendering by only updating the parts of the document that have changed.
For a deeper dive into this concept, check out thisarticle comparing the virtual DOM and the traditional DOM.
Popular SPA libraries like React, and frameworks such as Vue, Svelte, and Angular, dominate web development today. They excel in state management and interactivity. In React, for example, you might write something like this to handle state:
export const Counter = () => {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count+1)}>Increase</button>
<p>{count}</p>
</>
)
}
This pattern can also allow us to call an API endpoint, retrieve data, and save it in state. However, as the app grows and we store more data in the same manner, we risk too much data being held in the browser's memory, which can strain the user's system. Chrome tabs can show you how much memory each page is consuming, often in megabytes. Teams can start modifying APIs other portions of the app rely on and cause downstream faults in the UI.
This is where micro frontends shine. Inspired by microservices on the backend, the idea behind micro frontends is to break down the frontend monolith into smaller, independent applications that function cohesively in the user interface. Instead of relying on one massive codebase, each team owns and deploys a specific part of the frontend — say, checkout, user profile, or product display.
How Micro Frontends Address SPA Limitations
Improved Scalability and Team Autonomy : Each micro frontend operates independently, so developers can deploy new features or fixes without worrying about breaking the entire application.
Optimized Resource Management : By only loading the necessary parts of the application, micro frontends can reduce browser memory usage, particularly beneficial for users on low-powered devices.
Technology Flexibility : Teams aren’t locked into one SPA framework. They can choose the best tools for each module, such as React for a highly interactive component or vanilla JavaScript for simpler parts.
Continuing with the example above of an e-commerce site, we can structure our frontends to accommodate flexibility in technology and improve collaboration through well-defined APIs and communication channels. Teams have to be able to communicate effectively when working in separate Software Development Lifecycles (SDLCs) yet building an app that looks and feels cohesive.
The Checkout domain can be handled by a team or couple developers and so on.
Once you have decided to try out a micro-frontend approach, you have the choice of figuring out how to implement it. This is your decision overall, but there are some common patterns and approaches that may help your choice of implementation.
Micro-Frontend Patterns
There are several key patterns in micro-frontend architecture, each with unique approaches to organizing and deploying frontend code.
1. Module Federation
Module Federation is a pattern introduced with Webpack 5 (thank you Zack Jackson), allowing JavaScript applications to share modules at runtime. This enables applications to dynamically load code from other apps, making it ideal for micro-frontends where separate teams work on distinct parts of an application.
How It Works :
- Each micro frontend can expose components or modules, allowing other parts of the app to dynamically import them at runtime.
- Teams can update specific micro frontends independently without requiring full app redeployment.
- Module Federation supports version management, so if two components need different versions of the same library, they can load the appropriate version separately
2. Iframe-Based Isolation
Using iframes is one of the simplest approaches to micro frontends. Each micro frontend is loaded within an iframe, providing natural isolation since iframes are sandboxed already (throwback to integrating checkout forms, technically you implemented a micro-frontend, congrats).
How It Works :
- Each feature or micro-frontend serves as an independent application and is embedded in the main app using iframes.
- Communication between the main app and iframes typically occurs via postMessage or a shared API.
- This method ensures strong encapsulation, as each micro frontend has its own DOM and CSS context.
Cons :
- Limited styling and UX customization due to iframe boundaries.
- Can impact performance, especially with multiple iframes on the same page.
Applications with loosely connected micro frontends, like content from third-party services or specialized tools, do not need heavy integrations like some of the other patterns.
3. Web Components
Web Components provide a standardized way to create encapsulated, reusable components using HTML, CSS, and JavaScript. They are supported natively in most modern browsers, allowing teams to build micro frontends independently of specific frameworks. These are great for modular design systems.
How It Works :
- Each team creates frontend components as custom elements, which can be registered and used throughout apps.
- Styles and logic within Web Components are encapsulated, so each micro frontend has its own CSS and JavaScript environment.
- Framework-agnostic, so each team can choose its preferred tech stack.
- Allows for strong encapsulation with shadow DOM, making it easier to prevent style and script conflicts.
Cons :
- Building complex applications with Web Components can be challenging without dedicated tooling and libraries.
4. Client-Side Composition
In client-side composition, the main application orchestrates and loads micro frontends directly in the browser, often through APIs or dynamic imports. Think about separate routes being for the different SPAs.
How It Works :
- The main application makes API calls to load micro frontends at runtime based on routes or other user interactions.
- Often combined with Module Federation or Web Components to dynamically import the right code.
- Client-side routing libraries, like React Router, can help determine which micro frontend to load based on URL or application state
Cons :
- Adds complexity to the client-side logic.
- It can increase initial load times if not optimized with lazy loading or code splitting.
5. Server-Side Composition (Edge-Side Includes)
In server-side composition, each micro frontend is rendered on the server before being delivered to the client. Techniques like Edge-Side Includes (ESI) are used to assemble different parts of the app at the CDN or server level, reducing client-side load. This is useful for content-heavy applications where micro frontends contain static or infrequently updated content, like blogs and news sites.
How It Works :
- The server or CDN pulls in HTML fragments from different micro frontends and assembles them into a single response for the client.
- Faster initial load times and improved SEO, as content is rendered on the server.
Cons :
- Requires additional server setup and configuration.
- It is not ideal for highly interactive or state-heavy applications, as updates rely on server-side changes.
Each of these patterns suits different needs, depending on your application’s architecture, team structure, and desired level of independence for each micro frontend, they can set your frontend up for large-scale loads while keeping a good user experience and dev experience.
These do not have to be for large complex teams, a great example of this is in HelloFresh’s retelling of implementing micro-frontends:
At first, we found development speed to be a bit slow. Since this approach requires you to own your entire project E2E, the learning curve can be steep. But after cresting the curve we were able to push out projects like never before; the new HelloFresh shop for example took 3 people 4 weeks to roll out. Our developers also claim increased confidence and breadth of knowledge after adopting this approach. ~ Front-end Microservices at HelloFresh; Pepijn Senders Published in HelloTech
Implementation
We are going to be implementing a module federation pattern for our front-end microservices. Let's create an application that is separated by domains and load the modules or components in at runtime!
You will need some specific tooling installed if you would like to run this demo locally:
- NodeJS
Local Development Demo
I was inspired by Module Federation. They have great guides and examples and how to set up your apps to use Module Federation with plenty of Frameworks that use Webpack, Vite, or RsPack.
Let's make a Host app (React) and a Remote app (Vue).
To get started we can run a handy script from Jack Herrington npx create-mf-app for both the Vue app and the React Host app.
- Let’s create a Card element in the host
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.scss'
const App = () => (
<div className="flex items-center justify-center min-h-screen bg-gray-200">
<div className="relative p-8 bg-white border-4 border-black shadow-[8px_8px_0_0_rgba(0,0,0,1)]">
<h1 className="text-2xl font-bold text-black">Neo Brutalist Card</h1>
<p className="mt-4 text-black">
This is an example of a Neo Brutalist card styled with bold borders,
stark contrasts, and a playful shadow effect.
</p>
</div>
</div>
)
const rootElement = document.getElementById('app')
if (!rootElement) throw new Error('Failed to find the root element')
const root = ReactDOM.createRoot(rootElement as HTMLElement)
root.render(<App />)
Now this looks nice, but we need a button that does some mind-blowing functionality. I needed to add Vue and HTML to the tailwind config for the styling to work in the app. In the remote app:
//src/counter.vue
<template>
<button
@click="increment"
class="relative px-8 py-4 text-2xl font-bold text-black bg-white border-4 border-black shadow-[8px_8px_0_0_rgba(0,0,0,1)] hover:shadow-[12px_12px_0_0_rgba(0,0,0,1)] active:translate-x-2 active:translate-y-2 active:shadow-[4px_4px_0_0_rgba(0,0,0,1)] transition-transform duration-150"
>
Counter: {{ count }}
</button>
</template>
<script>
import { ref } from "vue";
export default {
setup() {
const count = ref(0);
const increment = () => {
count.value++;
};
return {
count,
increment,
};
},
};
</script>
<style>
/* No additional styles needed; everything is styled with Tailwind CSS */
</style>
<template>
<div class="flex items-center justify-center min-h-screen bg-gray-200">
<NeoBrutalistButton />
</div>
</template>
<script lang="ts">
import NeoBrutalistButton from "./counter.vue";
export default {
components: {
NeoBrutalistButton,
},
};
</script>
Run the app with npm run startand see this awesome functionality. Now we need to use the actual Module Federation! We need to do some things with Vue first, creating a mounting component due to the rendering being different than how React renders, then we export it in rspack.config.js for our host app to consume:
//counterMounter.ts
import { createApp } from "vue";
import Counter from "./counter.vue";
export default (el) => {
createApp(Counter).mount(el);
}
//rspack.config.js
imports blah blah
const deps = require("./package.json").dependencies;
...
plugins: [
new VueLoaderPlugin(),
new rspack.container.ModuleFederationPlugin({
name: "remote",
filename: "remoteEntry.js",
exposes: {
"./Counter": "./src/counterMounter",
},
shared: {
...deps,
},
}),
...
Now restart the dev server. There should be a new exposed file at http://localhost:9000/remoteEntry.js and this is what our host app consumes!
So in our host rspack.config.js:
...
plugins: [
new rspack.container.ModuleFederationPlugin({
name: "host",
filename: "remoteEntry.js",
exposes: {},
remotes: {
remote: "remote@http://localhost:9000/remoteEntry.js",
},
shared: {
react: { eager: true },
"react-dom": { eager: true },
"react-router-dom": { eager: true },
},
}),
...
And in our host application with updated code:
// src/App.tsx
import './index.scss'
import Counter from "remote/Counter"
const App = () => {
const ref = useRef(null);
useEffect(() => {
Counter(ref.current)
}, [])
return(
<div className="flex items-center justify-center min-h-screen bg-gray-200">
<div className="relative p-8 bg-white border-4 border-black shadow-[8px_8px_0_0_rgba(0,0,0,1)]">
<h1 className="text-2xl font-bold text-black">Neo Brutalist Card</h1>
<p className="my-4 text-black">
This is an example of a Neo Brutalist card styled with bold borders,
stark contrasts, and a playful shadow effect.
</p>
<div ref={ref} />
</div>
</div>
)};
You can get the complete code in my repo https://github.com/joshbrgs/medium-tutorials in the micro-frontends directory.
Deploying
With each being an independently deployable app, and the host consuming the modules, anything new with the remote app will change that portion of the host app at runtime! Teams can work in independent code bases and have their software development lifecycles while users see one cohesive application. You can try this for yourself locally or deploy to s3. Change one application and reload the page after it is deployed (or saved #HMR locally), you will see the changes immediately!
Conclusion
Engineering an application is a game of trade-offs. Each pattern has its pros and cons, and it comes down to figuring out which tradeoffs you make that will allow you to keep the extensibility of your programs without putting you into a corner that impedes your progress. Micro-frontends and their patterns may be a good fit to keep some options open for your UI’s extensibility, however, the common trade-off is sacrificing the speed of delivery shortly when you are setting up the architecture. I hope this introduced an easy way to incorporate micro-frontends into your next project!
References
Posted on November 18, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.