Sharing Dependencies in Micro Frontends

How to share dependencies in Micro Frontends

Image by Myriam Zilles from Pixabay

This year microfrontends went from a rather exotic technology to a mainstream trend. Many new frameworks, libraries, and tools have been published. While different patterns for implementation exist, there are some problems that will appear in one form or another in any of these patterns.

One of the most difficult problems is sharing dependencies. Like for microservices in the backend, there is no silver bullet here. Sharing no code at all can be problematic for many reasons (development performance and scalability, consistency, …). On the frontend, sharing code may even be more important, as (rendering) performance is a key metric for successful user engagement.

In this article, I’ll try to summarize some of the ways of sharing dependencies in a frontend application. Some of these patterns are not really tied to microfrontends, even though the problem can be seen earlier when developing such an application.

But let’s back up for a moment… Let’s have a look why sharing dependencies is difficult first.

Tip: use Bit (GitHub) to “harvest” UI components from your codebase and share them to a collection in bit.dev. Let you and your team reuse components in multiple micro frontends to speed up development and keep a consistent design.

Example: searching for shared components in bit.dev

Problem Description

The problem with dependencies is in the implicit relationship between the used dependency (e.g., a package) and the dependency’s user (e.g., an application). This relationship even though beneficial for many reasons, brings a lot of struggle and burden.

First, ensuring that the dependency is indeed doing what it should do. While obvious at first (we currently use it during development, right?) this becomes challenging during the maintenance phase. Potential breaking changes, security concerns, and side-effects concern us during updates of a dependency.

But also when remaining at the same version we may face challenges: The left-pad disaster has showed us that relying on a (single) package registry may be problematic, too.

Second, dependencies come with unknown baggage. Quite often — especially when just used to solve a simple problem — the overhead may not be really worth the gained functionality. This becomes even more problematic with dependencies of dependencies (also known as transitive or indirect dependencies). The situation with these gets complicated quite easily. A good criteria for choosing dependencies is thus to look at its direct dependencies and — if the list looks okay — check the added weight by using a tool such as Bundlephobia.

Dependencies may come with other dependencies, which can result in quite some weight.

Third, every dependency comes with its own license. While trivial at first, for large enterprises this comes with a challenge: Ensuring that all used dependencies meet certain license criteria. As enterprises present viable targets for lawsuits their legal departments usually give out some guidance or rules for what licenses to allow. There are, of course, tools such as WhiteSource to manage this with ease.

However, we don’t want to go too deep into the direction of “dependencies are evil”. Actually, dependencies are — for reasons already listed — also awesome. Still, especially in microfrontends, there is an issue with the overhead of including dependencies. The issue comes from having the same or “nearly” the same (i.e., only variations in the patch level) dependency in multi microfrontends.

Duplicating dependencies is worse than duplicating code.

Bundling Dependencies

Sometimes dependencies are quite lightweight and would only cause headache when being shared at run-time. Instead, we can only package the shared code in a dedicated library / NPM package and reference the package in all used microfrontends. While this will result in duplication for the different microfrontends, the burden of dependency management is taken from the application shell.

Bundling of a dependency.

There are, however, optimizations that can be applied here, too. When we work with Siteless UIs the feed service where we publish our microfrontends to could detect a sharing scenario. The scenario is triggered in case of the same version of the dependency being used in multiple microfrontends. In such cases one of the patterns described in the next sections could be applied.

While the optimization is certainly complex in nature, it can bring great freedom to the developer. At last we are in charge of the exact dependency, however, run-time optimizations can still be applied consistently.

Global Distribution

A possible way to share to dependencies is to make them available globally via the window context. As such, React could be accessed via a global variable called React . While this way shares all the drawbacks of the app shell distribution mentioned in the next chapter it comes with one clear advantage, too:

Sharing via the global context implicitly makes a statement regarding API stability. The API is clearly not in your hands.

This implies that further checking (e.g., what version is available) may be required before proceeding with any logic. In case of unmet requirements at least a warning should be emitted.

Global distribution of a dependency.

There are, however, additions to standard sharing. The global sharing could be enhanced via an asynchronous module loader that takes two parameters — the name of a package and the desired version (or version specifier). Either an already resolved dependency would then be returned or the dependency could be loaded and then returned, too. In any case the version would be more selective and up to the requester.

The complexity of this solution is certainly beyond anything acceptable — at least we don’t want to re-implement a dependency resolution with unknown depth in the browser.

App Shell Distribution

A convenient way to share dependencies is via the app shell directly. When the app shell already bundles certain dependencies, e.g., React , these dependencies can be made available to the microfrontends without any additional cost.

In this way the sharing go actually be achieved via two different ways:

Either we still use “classic” references in the microfrontends (e.g., import * as React from 'react' ) or

) or We use them from the app shell via a global object or a special app shell object (e.g., const React = appShell.dependencies.React )

If possible, we should use the former as this way works much nicer with existing tooling. Furthermore, a solution in this space is also more decoupled from the app shell concept, making it quite flexible for later use.

Referencing a dependency provided by the app shell.

The complexity of the former solution stems mostly from the use of bundlers. Since bundlers are quite necessary these days to tame the complexity of modern web apps, we need to place nicely with them. When a bundler detects an import statement it immediately wants to bundle the dependency, too. While there may be ways around it (e.g., Webpack has externals), we need to care about how these are used later on at run-time.

One way of specifying the externals quite easily without having to deal with bundler configuration is via the package.json. For instance, the peerDependencies could be used to specify what should be treated as external:

Alternatively, we could introduce a new field such as externals that list all packages to treat as external (i.e., are shared via the app shell):

Of course, these two ways could be combined, too.

A good interplay between the bundling of the app shell (providing the shared dependencies) and the microfrontends (using the shared dependencies) is required. We need a way to either globally tell the microfrontends what dependencies are available already, or locally when they are instantiated.

The biggest problem with an app shell distribution is, however, not the complexity of making it technically work. The biggest issue is certainly the nontransparent usage problem.

When a microfrontend is dependent on the app shell we will not be able to tell when the microfrontend will be broken just by looking at the app shell. As such an independent release cycle of the app shell may just include a minor change (e.g., going from React version 16.1.2 to React version 16.1.3), but due to small internal changes things may be break.

Even worse, a bundled dependency of a microfrontend may dependent on an implicit dependency of a changed dependency. This could easily go sideways, too. Long story short: In order to be sure we did not break anything, the best strategy is to simply freeze all shared dependencies.

Freezing dependencies is, however, a nightmare on its own. What about security updates? What about useful convenience updates? Could you “live” without React hooks?

Now obviously, we have multiple choices at this point. We can go back to bundling a dependency if the version provided by the app shell is insufficient. We could also place some tooling in between and handle such problems more or less autonomous. Either way, we have another problem. There is no silver bullet.

Consider React: Let’s pretend its shared via the app shell (and yes, this version of React has hooks). Tomorrow another version of React is released that includes a super cool way of handling asynchronous requests to resources. What should we do? We can just bundle it, right? Wrong.

In order for hooks (and many other things) to work properly in React a module-static state (i.e., singleton) is used.

If we bundle another version of React, we will have duplicated (or rather replicated) its internal state. As such things will just not work any more — or work in a weird way. Hooks give us such an indicator right away as without the exact same module (using its internal state) the dispatcher is not set properly.

Other packages work similarly, so we can expect that everything except helper libraries and component collections will potentially fall apart.

Before we fall into the pessimistic trap here: There is not so much to worry about. We should just keep the shared dependencies minimal and stable from the outside. Any microfrontend using internals of the shared dependencies should either be flagged automatically, or will need to refine in case of breaking changes. These should be super edge cases though.

URL Sharing

So far we went from “I have my dependencies” to “I see where to get the dependencies” and “the dependencies are given to me”. Now we try to follow a middle way — as we will see in multiple aspects.

Up to this point we learned:

bundling the dependencies gives us the best independence, yet the worst performance

distributing dependencies globally comes with multiple drawbacks, but at least puts us in the position to choose

sharing dependencies via the app shell is technically challenging yet super efficient

Nevertheless, how can we spare bundle size yet still decide independently what version to choose? The answer may lie in URLs.

When the dependency is reachable via an URL we can just get it from there. As a result, the bundle size is always ideal (as the dependency is always treated like a split bundle) and we will always end up with the desired resource.

Referencing a dependency directly via its (remote) URL.

Sounds great? Let’s look at where it shines and what pitfalls await us. Let’s start with sharing such dependencies.

Let’s say microfrontend A and microfrontend B reference the exact same URL. This would be a shared dependency. Yet, even though both may include / reference the script appropriately, the script is only downloaded once. It is, however, still evaluated twice — so scenarios as described in the previous section may still occur. Hence this only qualifies for helper libraries and component collections, too.

Wait a moment! How about all the savings we are supposed to achieve here? Well, not so fast… What was described so far is the naive approach, where we use the import() mechanism of bundlers without modifications. We could also mix this with the techniques gained by the app shell distribution.

Let’s first see the naive approach in action:

Using a host like unpkg gives us access to an online module system. Now we only need to be ready to consume these UMD modules.

Import Maps

So far this sounds like the right direction — if only it would be easier to use. Luckily, a spec is on the way to make this super trivial to use: Import maps.

A proposal for the import map is defined in the W3 community group.

In a nutshell it provides a way to map short names like l odash to an URL such as https://example.com/lodash.js .

After all, using import maps will work great together with ES Modules used in the browser. Under such circumstances a file using a “synchronous” import (e.g., import * as fp from 'lodash/fp' ) will still just work - even though Lodash was not resolved via some URL, which was loaded asynchronously.

Referencing a dependency provided by a (remote) URL specified in an import map.

Right now the spec demands to specify the import map with a new kind of script :

Note that the inline script can also be replaced via an external JSON reference using the src attribute.

Some polyfills for this spec already exist, with the most notably being SystemJS. In SystemJS the import maps script looks as follows:

It thus follows the spec quite exactly, with the exception being the used type , which is prefixed to avoid collisions with the spec. Besides standard imports SystemJS also supports scopes , which allow given modules to use dedicated versions of dependencies.

Conclusion

Managing dependencies has become one of the major issues in modern software development. The attitude of “nothing is shared” is certainly appealing, but faces strong limitations concerning scalability, security, and performance. On the other hand, the attitude of “everything is shared” faces strong limitations in the areas of scalability, flexibility, and robustness, too.

Finding a middle way will not be possible in general, however, for a selected problem we can come up with a subset selection that just works in one of the given categories. A combination of the listed ways to manage dependencies in a microfrontends architecture is doable.

In the end, having great dependency management is not only a question of the used framework, but also of the available tools. A sound solution uses available standard tools (e.g., NPM Audit) and existing standards (e.g., semantic versioning) mixed together with a custom software that is tailored towards the specific needs.

Learn More