Understanding module federation
Multiple separate builds should form a single application. These separate builds act like containers and can expose and consume code between builds, creating a single, unified application.
This is often known as Micro-Frontends, but is not limited to that.
Module Federation is a Webpack feature that provides a way to develop applications with multiple independent teams that have separate build processes and deployments, while still benefitting from a monolithic system at runtime. It allows applications to dynamically load code from other applications and share their dependencies. If an application does not have the dependency that a federated module requires, Webpack will fetch it from the federated build origin. Federated code can always load its own dependencies, but it will attempt to use the consumer's dependencies before downloading more payload. This means that multiple independent builds proactively minimize the payload at runtime by checking if their dependencies have already been loaded.
Concepts:
Host: a Webpack build that is initialized first during a page load (when the onLoad event is triggered)
Remote: another Webpack build, where part of it is being consumed by a “host”
Bidirectional-hosts: when a bundle or Webpack build can work as a host or as a remote. Either consuming other applications or being consumed by others — at runtime
Simple host / remote
Let's configure a simple host/remote setup. This requires two applications: one acting as a host that's responsible for loading another independent build (remote).
Webpack config for a host:
module.exports = {
// other webpack configs...
plugins: [
new ModuleFederationPlugin({
name: 'app1',
remotes: {
app2: '<http://localhost:3002/remoteEntry.js>',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
]
}
Webpack config for a remote:
module.exports = {
// other webpack configs...
plugins: [
new ModuleFederationPlugin({
name: 'app2',
library: { type: 'var', name: 'app2' },
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
}
The two main concepts of Module Federation can be seen in these examples: exposed modules and shared modules.
Exposed modules which can also be referred to as containers are a way for an application to expose a module for external consumption. These modules are exposed asynchronously, which allows each build to put exposed modules in separate files, together with their dependencies. This way, only used modules have to be loaded, but each build can still bundle modules together.
The other concept, shared modules, is also shown here. Each build can define shared modules into the shared scope, along with version information. Applications are then able to consume shared modules from the shared scope, along with a version requirement check. At runtime, Webpack will deduplicate shared modules in a way that provides each party with the highest available version of a shared module that meets the semver
version requirement. Shared modules are also provided and consumed asynchronously, so providing shared modules has no download cost. Only used/consumed shared modules are downloaded. For this to work both the remote and host have to add the same dependency in "shared", making it available for consumption.
Consuming a module from a remote:
import React from 'react';
const RemoteButton = React.lazy(() => import('app2/Button'));
const App = () => (
<div>
<h1>Basic Host-Remote</h1>
<h2>App 1</h2>
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
</div>
);
export default App;
Bi-directional Hosts
Each build can act as either a host or remote depending on how it’s loaded at runtime. If we add a third application to our initial setup and that build exposes a module consumed by a remote then that remote becomes bi-directional.
Here’s a Webpack config for a third remote:
module.exports = {
// other webpack configs...
plugins: [
new ModuleFederationPlugin({
name: 'app3',
library: { type: 'var', name: 'app3' },
filename: 'remoteEntry.js',
exposes: {
'./Login': './src/Login',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
}
At scale
When dealing with a large number of distributed teams, you'll also have to deal with a large number of federated applications which will be exposed and consumed across the organization for a multitude of different use cases. This poses some issues, as at a large scale it's harder to know which remotes exist and what they expose.
One thing you might have noticed from the examples above is that the URLs are hard-coded in the Webpack configuration. While this setup works for smaller examples and use cases, it won't scale with a large number of apps distributed across multiple environments. Additionally, if a remote URL changes, configurations need to be updated. This would require an understanding of a likely complex graph of dependencies, and changes across projects belonging to separate independent teams.
This is a common challenge for distributed systems on the backend, usually solved by implementing a service discovery pattern. However, this pattern is not usually seen in frontend applications or even in microfrontends implementations.
The service discovery pattern describes a method for a service location to be associated with a unique identifier and accessible through a remote service. Providing autonomy for a service to update its location without the need to coordinate with its consumers. Therefore to ensure efficient management of remotes in a large scale environment, it seems essential to implement a service discovery pattern.
Module federation provides the capability to configure a dynamic approach for retrieving remote sources. With this technique, Webpack can send requests to a service to obtain a particular remote location. Various strategies are available to achieve this. An example would be:
new ModuleFederationPlugin( {
name: 'someapp',
filename: "static/chunks/remoteEntry.js",
exposes: {},
remotes: {
remote1:`promise new Promise(resolve=>{
fetch('http://sdiscovery.com/remotes?remote=remote1').then(res=>{
return res.json()
}).then(res=>{
const {remoteUrl, globalName} = res
injectScript(remoteUrl).then(()=>{
resolve(window[globalName])
})
})
})`
},
},
)
Version mismatches
In the examples above we didn’t declare a specific version for our shared dependencies, in this situation Webpack will use semver
to determine which version to load at runtime, however it’s also possible to either set a specific version or a range of accepted versions.
module.exports = {
// other webpack configs...
plugins: [
new ModuleFederationPlugin({
name: 'app3',
library: { type: 'var', name: 'app3' },
filename: 'remoteEntry.js',
shared: {
react: {
singleton: true,
requiredVersion: "^18.0.0",
},
"react-dom": {
singleton: true,
requiredVersion: "^18.0.0"
},
}),
],
}
The important fact to highlight here is that each application must declare the shared dependencies it needs. While manually adding vendors or other modules to shared is not ideal on a large scale, this can be automated with a custom function or with a supplemental Webpack plugin. However, this comes with some trade-offs in terms of performance versus risk. Since semver
is a social contract, there is no guarantee that a compatible range will not break your application. And if you need to test all remotes together to increase dependency versions, you are also increasing coupling and making it harder to release. Therefore, this can be a difficult issue to manage, posing some difficult decisions.
Wrapping up
Module Federation is an interesting way to create independent applications while still being able to have shared modules and dependencies at runtime, addressing one of the biggest constraints of microfrontend architectures. It allows for code to be dynamically loaded from other applications and shared code to be reused at runtime. However, this also comes with some tricky challenges, such as defining versions of shared dependencies and managing version mismatches.
On next issues i plan to explore more about server side rendering with module federation which seems to introduce significant complexity and some patterns that can be useful when using module federation in large projects.
Resources
https://dev.to/infoxicator/module-federation-shared-api-ach
https://oskari.io/blog/dynamic-remotes-module-federation
https://github.com/sokra/slides/blob/master/content/ModuleFederationWebpack5.md
https://github.com/module-federation/universe/discussions/442