Import Maps – The Next Evolution Step for Micro Frontends?

Future-proof Module Federation with browser technologies

Module Federation greatly helps with implementing Micro Frontends. However, it is currently tied to webpack. This isn't a big problem in the short-term or even in the mid-term, because with over 20 million downloads, webpack is currently the most popular build tool for web technologies.

On the other hand, we know that technologies come and go. For example, the Angular team is currently working on CLI support for the bundler esbuild, which is very promising in terms of build performance.

This raises the question of how to implement the tried and tested mental model of Module Federation independently of webpack in order to future-proof your own micro frontend architecture. This article provides an answer based on Import Maps, an emerging Web Standard meanwhile supported by all major browsers.

📂 Source Code

Mental Model Behind Module Federation

Before we talk about Import Maps, I would like to say a few words about the mental model of Module Federation that we want to model later with Import Maps:

OverviewThis mental model defines, among other things, host applications that load modules of a separately built and separately deployed remote application. Such modules can contain all sorts of JavaScript constructs: functions, components, as well as data structures like Angular modules or routes are a few common examples. In the world of micro frontends, the host is a shell, while the remote is the micro frontend itself.

The host loads the provided modules with a dynamic import or – if the remotes are not known in advance – via a low-level API offered by wepack at runtime. The latter is indicated in Figure 1 with the helper function loadRemoteModule . This function references the name of the exposed module (exposedModule) and a remote entry (remoteEntry).

The remote entry is a JavaScript file with metadata about the remote that webpack generates during bundling. Among other things, this metadata informs at runtime about the dependencies to be shared between the remotes and the host. These dependencies are to be entered in the configuration under shared.

For example, the example in Figure 1 specifies that @angular/core should be shared. This means that @angular/core is only loaded once, even if it is used in several remotes or in the host.

Version conflicts can of course arise when dependencies are shared. Long-serving Windows users may still know this problem under the name DLL-Hell. Fortunately, Module Federation comes with several strategies to avoid conflicts:

  • Just loading the highest compatible version: By default, Module Federation only loads the highest compatible version of a shared dependency. For example, if the host uses version 10.0 and a remote uses version 10.1, Module Federation might decide to load only version 10.1, since it should be backward compatible with version 10.0.
  • Loading multiple versions: If two or more applications require incompatible versions, Module Federation loads both versions. This is the case, for example, when the host references version 10.0 and the remote references 11.0.
  • Prevent multiple versions: Module Federation can be instructed to treat a split dependency as a singleton. In this case, only the highest version will be loaded, regardless of whether it is backward compatible or not. If there is a risk of a version mismatch, Module Federation issues a warning on the JavaScript console. If desired, module federation can also trigger an exception instead, so that e.g. Integration tests quickly become aware of the problem at hand.

Whether two versions are compatible with each another can be found out by looking into the project’s and/or dependency’s package.json. For example, ^10.1.7 means that a higher version within major version 10 is also allowed. At runtime, this information can be found in the metadata of the remote entry. This metadata is therefore of central importance when resolving version conflicts.

Import Maps - an Underestimated Browser Technology

To motivate Import Maps, the following listing uses an example that checks whether a public holiday leads to a long weekend or to a bridging day:

import { format , parseISO } from 'date fns';
import { isLongWeekend } from 'is-long-weekend';
import { isBridgingDay } from 'is-bridging-day';

const date = parseISO('2023-01-01');
const weekday = format(date, 'EEE');

console.log(<code>It&#039;s a ${ weekday }.);

if (isLongWeekend(date)) {
    console.log ( 'Long weekend 😎 ' );
}
else if ( isBridgingDay ( date )) {
    console.log('Bridging day 😎 ');
}

The well-known library date-fns is used for this check. Also, the solution relies on two helper functions, isLongWeekend and isBridgingDay, located in other modules.

The special thing about it is that the example runs directly in the browser, as the script tags indicate. The question now is how the browser resolves the import statements. Normally, a build tool takes care of this by putting the individual source code files in one bundle or in several bundles related via some generated glue code.

Here, however, the browser manages the individual imports at runtime. It takes the necessary information from an import map:

<script type="importmap">
{
    "imports": {
        "date-fns": "./libs/date-fns.js",
        "is-long-weekend": "./js/is-long-weekend.mjs",
        "is-bridging-day": "./js/is-bridging-day.mjs"
    }
}
</script>

The import map maps the names from the import statements to specific JavaScript files. date-fns.js is a bundle previously built with esbuild and contains only the date-fns library. This avoids the browser having to load the many files from date-fns individually.

As mentioned at the beginning, meanwhile, all major browsers support Import Maps:

ImportHowever, the Import Maps support for Safari was still in Technology Preview when writing this. Hence, for Safari but also for older browsers, we need a polyfill. Fortunately, there is a production ready polyfill for Import Maps that that has been implemented with performance in mind and also provides additional features for dynamic scenarios.

Avoiding Version Conflicts With Scopes

Import maps offer so-called scopes to resolve version conflicts. For example, the following listening uses a scope for the is-bridging-day.mjs file:

<script type="importmap">
{
    "imports": {
        "date-fns": "./libs/date-fns.js",
        "is-long-weekend": "./js/is-long-weekend.mjs",
        "is-bridging-day": "./js/is-bridging-day.mjs"
    },
    "scopes": {
        "/js/is-bridging-day.mjs": {
            "date-fns": "./libs/other-date-fns.js"
        }
    }
}
</script>

The scope shown specifies that the name date-fns within is-bridging-day.mjs refers to the other-date-fns.js. However, in other files, date-fns still points to the date- fns.js file specified under imports. At runtime, the dev tools show that the browser actually loads both versions:

TheIf, on the other hand, several scopes refer to the same file, it is only loaded once by the browser. When transferring the mental model from Module Federation to Import Maps, you could now set up a separate scope for each remote and use the file names stored there to determine whether the remote loads its own version or reuses the version of another remote.

Dynamic Import Maps and Version Negotiation

The import maps considered so far were self-written. However, this is hardly practical for large applications with many dependencies and remotes. Instead, it makes sense to generate the import map from metadata:

<script>
const myDateFns = {
    paths: './libs/date-fns.js',
    version: '2.29.2'
}

const otherDateFns = {
    paths: './libs/other-date-fns.js',
    version: '2.29.2'
};

function negotiate(my, other) {
    if (my.version === other.version) {
        return my.path;
    }
    return other.path;
}

const importMap = {

    "import": {
        "date-fns": myDateFns.path,
        "is-long-weekend": "./js/is-long-weekend.mjs",
        "is-bridging-day": "./js/is-bridging-day.mjs"
    },

    "scopes": {
        "/js/is-bridging-day.mjs": {
            "date-fns": negotiate(myDateFns, otherDateFns)
        }
    }
};

const im = document.createElement('script');
im.type = 'importmap';
im.textContent = JSON.stringify(importMap);
document.currentScript.after(im);
</script>

This simplified example assumes that the host is aware of its own metadata (myDateFns) and has loaded the remote's metadata (otherDateFns). It uses this to generate the import map, which is initially only available as a JavaScript object.

There is a separate scope for the remote. The negotiate function determines whether the remote gets its own version of date-fns or reuses the host's one. A slightly more extensive variant of this method could implement the strategies that module federation uses to resolve version conflicts (see above). In a further step, this example could be extended so that it derives the complete import map from the metadata.

At the end, the example creates a script tag for the import map and inserts it into the page. For this to work, no other script tags with type="module" may be placed in front of it. This restriction avoids a chicken/egg problem with browser-native implementations. However, the above-mentioned polyfill does not see this aspect quite so closely in the so-called shim mode and also allows import maps to be inserted later.

Externals, but Please With Imports!

Thanks to Import Maps, the browser now directly resolves the import statements that result in shared dependencies and remotes. But that also means that the bundler is not allowed to do this task in advance. Technically, the bundler must be instructed to simply include the corresponding import statements in the bundle 1:1 instead of including the referenced files in the bundle as well.

Most bundlers refer to such unresolved dependencies as externals, and usually such externals can be specified through the bundler's configuration. In the case of esbuild they are passed as an array:

await esbuild.build ({
    entryPoints: ["js/is-bridging-day.mjs", [...] ],
    [...]
    external: [ "date-fns" ],
    format: "esm" ,
    target: [ "esnext" ],
});

There are various solutions for realizing externals. The CommonJS modules originally used extensively by Node.js can request externals with the require function, for example. Other implementations expect the consumer to provide externals via global variables. However, since import maps are based on EcmaScript modules, we need the import statements in the bundles. The bundler esbuild used here takes care of it when the target format esm (EcmaScrpt-Modules) is requested.

Interim Conclusion: Promising, but Low Level

The previous sections have shown that import maps provide the necessary building blocks for replicating the Module Federation mental model. However, they offer too little abstraction. To be useful for really large applications, we need a superstructure that takes care of things like:

  • Providing and loading of meta data
  • Separate bundling of shared dependencies and remotes
  • Consideration of the Angular compiler
  • Generation of an import map including scopes for remotes
  • Handling version conflicts
  • Loading remotes

As with module federation, all of these tasks should also be fine-tunable using a simple configuration. Besides that, in the Angular world we need a CLI integration that sets up the solution with ng add or ng generate and triggers it when calling ng serve, ng build, etc.

The Solution: Native Federation

With the package Native Federation [native-federation] I want to fulfill the requirements outlined in the last section. It is based on the concepts presented in this article, is open source and provides the same API as the Module Federation plugin [module-federation-plugin], so existing knowledge can be reused. An example of a Native Federation configuration can be found the next listing:

const { withNativeFederation, shareAll }
    = require('@angular-architects/native-federation/config');

module.exports = withNativeFederation({

    name: 'mfe1',

    exposes: {
        './Module': './projects/mfe1/src/app/flights/flights.module.ts',
    },

    shared: {
        ...shareAll({ [...] }),
    },

});

Apart from the package name initially referred to as require, this configuration corresponds to the structure known from the Module Federation plugin [module-federation-plugin]. The helper function shareAll shares all dependencies that can be found in the package.json under dependencies. There is a helper function loadRemoteModule for loading remotes:

const m = await loadRemoteModule({
    remoteName: 'mfe1',
    exposedModule: './Module'
});

Although the current implementation is based on Angular and esbuild, the underlying design allows to interact with any SPA framework and bundler. Thus, the package sees itself as insurance for a possible future in which we want to work without webpack. Especially since micro frontends are used in large and very long-lasting projects, this option seems essential.

Is Native Federation Ready for Prime Time?

While the tooling- and framework-agnostic core of Native Federation has meanwhile been released in a version 1.0 the Angular integration is still in BETA and hence not still ready for prime time.

The reason is that for the Angular integration the CLI teams experimental esbuild-based builder is used. As soon as this builder is final, our integration will be adopted to it a be final too.

However, the current state of Native Federation can be seen as insurance for the mid-term: If the Angular community eventually moves away from using webpack under the hoods, you will still be capable of using the Mental Model of Module Federation by leveraging the then production-ready Native Federation.

Until then, your best option is using the proven webpack Module Federation. For this, you can go with our Angular CLI plugin.

Conclusion

Import maps offer all the necessary building blocks to emulate the mental model of Module Federation independently of individual bundlers: They allow remotes and shared dependencies to be loaded directly, can be generated dynamically using metadata and, thanks to scopes, allow version conflicts to be resolved.

However, since they offer too little abstraction, we need a superstructure that makes these possibilities more accessible. Ideally, this superstructure provides the same API as the Module Federation plugin module-federation-plugin so that we can continue to rely on existing knowledge.

What's next? More on Architecture!

Please find more information on enterprise-scale Angular architectures in our free eBook (5th edition, 12 chapters):

  • According to which criteria can we subdivide a huge application into sub-domains?
  • How can we make sure, the solution is maintainable for years or even decades?
  • Which options from Micro Frontends are provided by Module Federation?

free

Feel free to download it here now!