This is one of the most potent APIs of Module Federation, yet there needs to be more documentation on how it works and how it could help with the performance of your distributed applications.
In this post, we will explore every aspect of the Shared API, explaining how to use it and when it might come in handy. 🔍
What's the Shared API?
So, what is the Shared API exactly? 🤔 It's part of the plugin configuration options in Module Federation. You can pass it an array or object called shared
, which contains a list of dependencies that can be shared and used by other federated apps (aka "remotes").
new ModuleFederationPlugin({
name: "host",
filename: "remoteEntry.js",
remotes: {},
exposes: {},
shared: [],
});
API Definition:
shared (object | [string])
: An object or Array of strings containing a list of dependencies that can be shared and consumed by other federated apps.
eager (boolean)
: If true, the dependency will be eagerly loaded and made available to other federated apps as soon as the host application starts. If false, the dependency will be lazily loaded when it is first requested by a federated app.
singleton (boolean)
: If true, the dependency will be treated as a singleton and only a single instance of it will be shared among all federated apps.
requiredVersion (string)
: Specifies the required version of the dependency. If a federated app tries to load an incompatible version of the dependency, two copies will be loaded. If the singleton
option is set to true
a warning will be printed in the console.
Why do you need it?
When you have federated modules, they're bundled separately and include all the dependencies they need to function. However, when they're used in a host application, it's possible for multiple copies of the same dependency to be downloaded. This can hurt performance and make users download more JavaScript than necessary. 😰
This is where the shared API becomes really handy: You can avoid downloading multiple copies of the same dependency and improve the performance of your application. 🚀
Preventing Duplication
Let's stick to the typical example of Webpack, and share and deduplicate lodash
:
Let's say you have two modules, Module A and Module B, and both of them require lodash
in order to function independently.
But when they're used in a host application that brings both modules together, the Shared API comes into play. If there is already a preloaded, shared copy of lodash
available, Module A and Module B will use that copy instead of loading their own independent copies.
This copy could be loaded by made available by the host or another remote application inside it.
new ModuleFederationPlugin({
...
shared: ["lodash"],
});
Tip: Both the remote and host have to add the same dependency in "shared" for it to be available for consumption.
How does it work?
If you are familiar with Dynamic Imports, Module Federation is nearly identical; it requests a module and returns a promise that fulfils with an object containing all exports from the moduleName
declared in the exposes
object.
Module Federation's async nature makes the shared
API so flexible.
Async Dependency Loading
When a module is required, it will load a file called remoteEntry.js
listing all the dependencies the module needs. Because this operation is asynchronous, the container can check all the remoteEntry
files and list all the dependencies that each module has declared in shared
; then, the host can load a single copy and share it with all the modules that need it.
Because shared
relies on an asynchronous operation to be able to inspect and resolve the dependencies, if your application or module loads synchronously and it declares a dependency in shared
, you might encounter the following error:
Uncaught Error: Shared module is not available for eager consumption
To solve the error above, there are two options:
Eager Consumption
new ModuleFederationPlugin({
...
shared: {
lodash: {
eager: true,
},
});
Individual dependencies can be marked as eager: true
; this option doesn't put the dependencies in an async chunk, so they can be provided synchronously; however, this means that those dependencies will always be downloaded, potentially impacting bundle size. The recommended solution is to load your module asynchronously by wrapping it into an async boundary:
Using an Async Boundary:
Note: this only applies to the application's entry point; remote modules consumed via module federation are automatically wrapped in an Async Boundary.
To create an async boundary, use a dynamic import to ensure your entry point runs asynchronously:
index.js
import('./bootstrap.js');
bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
Versioning
So what happens if two remote modules use different versions of the same dependency?
💥?
Nope... everything just works™️!
Versioning is probably one of the best (if not the best) features of the shared API, and it is also enabled by default 🙌
If the semantic version ranges for those dependencies don't match, then Module Federation is powerful enough to identify them and provide separate copies, so you don't accidentally load the wrong version containing breaking changes. This obviously can cause issues with performance because you end up downloading different versions of a dependency, but at least your app doesn't break.
Singleton Loading
I know what you are thinking; what if I want to guarantee that only one copy of a given dependency is loaded at all times. (cough cough React, cough cough)
Passing singlton: true
to the dependency object achieves precisely that...
shared: {
react: {
singleton: true,
requiredVersion: "^18.0.0",
},
"react-dom": {
singleton: true,
requiredVersion: "^18.0.0"
},
},
Here's something to be aware of: if one of your remote modules tries to load an incompatible dependency version that has been marked as a singleton, Webpack will print the following warning in the console:
But don't panic! The build won't actually break, and Webpack will continue to bundle and load your applications. However, the warning is there to let you know that there may be some unexpected behaviour due to compatibility issues. So, if you see this warning, it's a good idea to go and align your dependencies to avoid any problems.
Downsides / Trade-Offs
Everything is a trade-off, but you can only accept and mitigate them if you are aware they exist. Here are some issues that you might encounter using the Shared API:
Dependencies mismatches at Runtime
Because the applications are built at a different point in time by another Webpack process, they don't share the same dependency graph, and we can only rely on Semantic Versioning ranges to deduplicate and provide the same dependency version.
There could be a case where you have built and tested your remote with version 1.0.0
of a library, but then when loaded by the host, because the Semantic Versioning Range ^1.0.0
satisfies 1.1.0
, you might end up loading 1.1.0
at Runtime in production that might or might not have compatibility issues.
This can be mitigated by aligning versions as much as possible (Using a monorepo with a single package JSON might help).
This downside concerns our trust in Semantic Versioning ranges rather than Module Federation and the Shared API. In Distributed Systems (similar to Microservices), a contract is required to guarantee the stability and reliability of the system. In the case of the Shared API, the Semantic Version Range is the contract (potentially not a good one).
From my experience, there isn't a better alternative to shared dependencies on a distributed frontend application, and even though the Share API is not perfect, it is the best we have right now.
Conclusion
In conclusion, the Module Federation Shared API is a powerful tool for improving the performance of distributed applications. It allows you to share dependencies between modules and avoid unnecessary duplication, resulting in faster load times and better overall performance