Micro-Frontends
Optimizing Performance by Managing Shared Dependencies and Bundle Bloat
Master dependency sharing in Module Federation to prevent loading multiple versions of React or other large libraries across your modules.
In this article
Orchestrating Global Singletons
To implement dependency sharing, developers use the Module Federation Plugin configuration within their build tool. The most critical part of this configuration is the shared property, which acts as a registry for common libraries. This registry tells the bundler which packages should be treated as external references rather than static imports.
The shared configuration supports several options that control how the runtime handles library instances. The singleton property is particularly important for libraries that manage application-wide state or use global event listeners. When a package is marked as a singleton, the system guarantees that only one version of that package will be instantiated throughout the entire session.
1const { ModuleFederationPlugin } = require('webpack').container;
2
3module.exports = {
4 plugins: [
5 new ModuleFederationPlugin({
6 name: 'order_service',
7 filename: 'remoteEntry.js',
8 remotes: {
9 shell: 'shell@http://localhost:3000/remoteEntry.js',
10 },
11 // Defining which dependencies to share with the host and other remotes
12 shared: {
13 react: {
14 singleton: true, // Only one instance of React should exist
15 requiredVersion: '^18.2.0',
16 eager: false // Load react only when it is actually needed
17 },
18 'react-dom': {
19 singleton: true,
20 requiredVersion: '^18.2.0'
21 }
22 }
23 })
24 ]
25};By setting the singleton flag to true, you inform the federation runtime that multiple versions of this library cannot coexist safely. If the system encounters two different versions of a singleton, it will attempt to pick the highest compatible version. This prevents the common issue of having multiple React contexts or event systems fighting for control over the browser.
Required Versions and Strict Versioning
The requiredVersion property allows you to specify a semantic version range that your module can support. This follows the standard npm versioning logic, using carats or tildes to define compatibility boundaries. If a remote needs a version outside of this range, the system must decide how to proceed based on your configuration.
If you enable the strictVersion property, the application will throw a runtime error if it cannot find a version that satisfies the requirements. This is a defensive programming technique used to prevent difficult-to-debug UI bugs caused by incompatible library versions. In production environments, this ensures that your micro-frontend never runs on a foundation it was not tested against.
The Role of the Host Application
The host application, often called the shell, usually takes the lead in managing the shared scope. It is responsible for providing the primary versions of core libraries to its child remotes. When the shell initializes, it populates the shared scope with its own versions, which remotes can then consume without downloading their own copies.
Remotes are designed to be resilient and can still function if they are loaded in isolation. If a remote is accessed directly and no shared scope is provided by a host, it will fall back to its own bundled version of the library. This dual-mode capability is essential for independent testing and local development workflows.
Solving Conflict Strategies and Trade-offs
Handling conflicts in a distributed system requires a clear strategy for version negotiation. When two modules request different versions of a non-singleton library, Module Federation can actually load both versions if they are functionally compatible. This approach avoids breaking the application but increases the total bundle size as a trade-off.
Managing these trade-offs involves making decisions about which libraries are core to the platform and which are module-specific. General utility libraries like Lodash are good candidates for sharing because multiple versions can often coexist without side effects. Frameworks and design system libraries require much stricter control to ensure visual and functional consistency.
- Singleton Strategy: Best for libraries with global side effects like React or Redux where multiple instances break the app.
- Version Range Strategy: Allows the highest compatible version to be used across all modules to reduce total downloads.
- Strict Versioning: Prevents execution if the available dependency does not perfectly match the requirements of the module.
- Eager Loading: Forces the dependency to be loaded upfront, which is useful for the host but can bloat the entry point.
It is often beneficial to automate the shared configuration by pulling dependency versions directly from the package.json file. This reduces manual maintenance and ensures that the Module Federation configuration stays in sync with the actual versions used during development and testing. This technique is especially useful in monorepos where many modules share the same root dependencies.
Automating Dependency Management
Manually updating version strings in a plugin configuration is prone to human error. Instead, developers can use the spread operator to import the dependencies object from their package file. This allows the build system to dynamically generate the shared configuration based on the current environment.
1const deps = require('./package.json').dependencies;
2
3module.exports = {
4 plugins: [
5 new ModuleFederationPlugin({
6 // Automatically sharing all dependencies listed in package.json
7 shared: {
8 ...deps,
9 react: {
10 singleton: true,
11 requiredVersion: deps.react,
12 },
13 'react-dom': {
14 singleton: true,
15 requiredVersion: deps['react-dom'],
16 },
17 },
18 })
19 ]
20};The Impact of Eager Loading
By default, shared dependencies are loaded asynchronously to prevent blocking the initial page render. This is usually the desired behavior, but it requires the application to have an asynchronous entry point. If the entry point is synchronous, the application will fail because the shared libraries are not yet available.
The eager property allows you to bypass this asynchronous behavior for specific modules. Setting eager to true is common for the host application because it needs those libraries immediately to render the shell. However, overuse of the eager flag in remotes defeats the purpose of code splitting and should be handled with extreme caution.
Optimization and Real-World Implementation
In a real-world enterprise environment, managing shared dependencies requires a governance model. Teams should agree on a set of core dependencies that are standardized across the organization. This might include the UI library, state management tools, and internationalization utilities that every micro-frontend is expected to use.
A common pitfall is sharing too many libraries, which creates tight coupling between independent teams. If every tiny utility library is shared, a change in one team's package version might force every other team to update their configurations. The goal is to share only the large, high-impact libraries that significantly affect performance or functional integrity.
Monitoring the shared scope is essential for long-term maintenance of a micro-frontend ecosystem. Developers should use bundle analysis tools to verify that only one instance of a library is being shipped to the browser. If the same package appears multiple times in the network trace, it indicates a misconfiguration in the singleton or version negotiation logic.
Successfully mastering dependency sharing results in a system that feels like a monolith to the user but acts as a fleet of microservices for the developer. This balance allows for rapid iteration and deployment while maintaining the high performance standards expected of modern web applications.
The Importance of Async Entry Points
To make Module Federation work correctly without the eager flag, you should use a bootstrap pattern. This involves creating a secondary entry file that imports the main application logic. The primary entry point simply uses a dynamic import to load this bootstrap file, allowing the federation runtime to resolve shared modules first.
This pattern provides a critical window of time for the runtime to negotiate versions and fetch the required chunks. Without this asynchronous bridge, the application would attempt to execute code before its dependencies are ready. Implementing the bootstrap pattern is considered a best practice for all micro-frontend architectures using Module Federation.
