Webpack module federation | Think twice before sharing a dependency

Martin Maroši
9 min readAug 21, 2023

Webpack module federation is an amazing tool that allows amazing optimization for large-scale front-end applications. Thanks to its ability to add new JS modules at runtime it has been called by many the micro frontend game-changer. And I could not agree more.

Webpack
https://webpack.js.org/

However, if we are not careful with how we share our dependencies we may end up with a large problem on our hands. The feature that is supposed to help us share our dependencies and prevent the constant loading of duplicate assets, can be the culprit that forces your application bundle size to skyrocket.

Join me on a short journey as I explain how to maximize the benefits of module sharing and prevent its possible negative performance impacts.

What is a webpack module federation?

Let the author, Zack Jackson give us a short description:

A scalable solution to sharing code between independent applications has never been convenient, and near impossible at scale. The closest we had was externals or DLLPlugin, forcing centralized dependency on a external file. It was a hassle to share code, the separate applications were not truly standalone and usually, a limited number of dependencies are shared. Moreover, sharing actual feature code or components between separately bundled applications is unfeasible, unproductive, and unprofitable.¹

Shared modules

The feature that we will be talking about today is module sharing. Specifically about project dependencies. This feature allows you to explicitly share modules between multiple “applications” with separate builds that occupy the same browser page.

Imagine that we have two applications and there is a common component, a button perhaps, that is used in both. It’s the exact same button, from the exact same library, with the same functionality. There is really no point in loading the asset multiple times to the browser.

The module-sharing feature allows us to share such assets across multiple applications. Provided they have the correct build configuration and the module meets the version requirements.

Usually, the configuration for a React application can look something like this:

new ModuleFederationPlugin({
shared: {
...deps,
react: {
eager: true,
singleton: true
},
'react-dom': {
eager: true,
singleton: true
},
},
});

If you have ever looked for some module federation examples, you have probably seen something similar. At first glance, there is not anything wrong with it. But, there are times when something like this can make your entire bundle size skyrocket. I am looking at you …deps . Let's discuss why.

Think twice before sharing a dependency

What dependencies should you share then? Ideally all of them. In an ideal environment, no module would be loaded twice. Regardless of how many “applications” are running. It’s not about what to share, but how we share them.

With some dependencies, the decision is simple. Dependencies like react and react-dom are core dependencies that we must share because we need everything from them.

But with others, it’s not that simple. Let’s use @patternfly/react-icons as an example. If you are not aware Patternfly is a UI design system that also has bindings for the React library. One of the bindings is the icons library. It provides a very sizable library of SVG icons. At the time of writing, it was 1731. With an unpacked size of 7.8 MB. That' can be a size of an entire front-end bundle. A large one. That does not mean the dependency is bad. It simply has a lot of modules. Usually, you will use just a few of the SVGs. And the footprint will be just a few KB. Tree-shaking will ensure we get rid of any JS modules that are not used. Or will it?

Let’s create two super simplistic applications. In one, we won’t share the @patternfly/react-icons dependency and in the other we will.

This is the source code:

import React from 'react'
import { createRoot } from 'react-dom/client';import { Icon } from '@patternfly/react-core'
import { CircleIcon } from '@patternfly/react-icons'

const App = () => {
return (
<div>
<h1>
React app
</h1>
<Icon>
<CircleIcon />
</Icon>
</div>
)
}
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App tab="home" />);

An app like this should be tiny. The react dependencies will be the majority of the final JS bundle. The webpack bundle analyzer agrees. The footprint of the @patternfly/react-icons package is 3.52KB.

Tree-shaken output with no dependency sharing.

Let’s do another build. This time we’ll share the icons dependency. We will add a new webpack plugin to the build configuration:

const { container: { ModuleFederationPlugin } } = require('webpack')
...
new ModuleFederationPlugin({
shared: {
'@patternfly/react-icons': {
version: '^5.0.0-prerelease.9'
}
}
}),

After we run the build with these settings, this is the result. Bare in mind, we have not touched the code at all. We still have only one component imported from the icons dependency.

You can see that there is a lot more “stuff” in our bundle. The @patternfly/react-icons footprint went from a paltry 3.52KB to 7.13 MB. How is that possible?

Build footprint of improperly shared dependency

Module sharing changes tree-shaking rules

Webpack and all module bundlers are usually pretty good at tree-shaking unused JS modules. If they weren’t, our applications would be the size of node_modules.

So what happened? Why does a simple configuration change have a massive negative impact on our bundle? The module sharing should help us, not to send our app into the stone age. Right?

The answer is quite simple. Webpack does not know what to tree shake from the icon’s dependency. Why? Imagine this scenario. We have two applications. Both require @patternfly/react-icons dependency. Each uses a different icon. We have decided to share the package. We have also somehow convinced Webpack to tree-shake the individual builds.

Say that our applications look like this:

// Application A
import { CircleIcon } from '@patternfly/react-icons'
// Application B
import { AdIcon } from '@patternfly/react-icons'

There is nothing else in these applications. Only a single icon. Note that the builds are separate with the same sharing configuration. Just a reminder. We have also somehow convinced Webpack to tree-shake the individual builds. This is not how Webpack works, but we need this assumption to explain the reason why tree-shaking is not working for shared modules.

When a module with shared-modules config is loaded into the browser, it goes through a procedure that decides which modules to use. The flow looks something like this (this is a very oversimplified schema):

The shared modules are retrieved and stored in a “cache” called webpack share scope. Whenever a module that is marked for sharing is being loaded, Webpack will first check the cache and try to get the modules from there. If it does not find it, or the version requirements are not met, webpack will load the module from the original build, by injecting a new script into the browser. In theory, if all shared dependencies are already loaded in the cache, Webpack will not pull any new JS into the browser. Saving precious resources for our users.

Now imagine that the builds are tree-shaken. Application A loads only the CircleIcon. The @patternfly/react-icons is not in the cache. The Application A will initialize it. The only icon that will be in the icons cache is the CircleIcon. The rest was tree shaken. Some time has passed, and now Application B is ready to be loaded. It will also try to retrieve the assets from the cache. Unlike with Application A, the @patternfly/react-icons is already within the cache. Webpack grabs the module from the cache and sends it to Application B.

But, the AdIcon is nowhere to be found in the cached module. It was tree shaken in the Application A build. Only then CircleIcon is there. If we would try to run this code we would see an error similar to this

Warning: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: undefined.
Check the render method of `App`.
at App

And here is why Webpack can’t tree-shake shared dependencies. Webpack does not know at build time which modules from a dependency will be used at runtime. It can’t remove anything because it does not know what might be required. And that is why we have all 1.7K icons included in the build. Even though we used only one.

What is the solution?

Get used to absolute import paths

We have to help Webpack with the tree-shaking. How do we do that? We use import paths that point directly to the actual JS module. Not every library has a build that supports this. Fortunately, @patternfly/react-icons build was adjusted to support absolute import paths.

Webpack is using the import path as the identifier of the module in the Webpack cache.

Import path change

An absolute import path change can look something like this.

- import { CircleIcon } from '@patternfly/react-icons'
+ import CircleIcon from '@patternfly/react-icons/dist/dynamic/icons/circle-icon'

The actual path depends on the project. There is no standardization for this type of build output.

Module sharing change

Because Webpack is using import paths as the cache identifier we also have to change the sharing configuration.

new ModuleFederationPlugin({
shared: {
- '@patternfly/react-icons': {
+ '@patternfly/react-icons/dist/dynamic/icons/circle-icon': {
version: '^5.0.0-prerelease.9'
},

Sharing modules like this by hand is not ideal. In a real example, you would probably use tools like glob to find all available modules. Don’t worry. Modules that are not used will not be included in the build. You don’t have to be afraid to only list modules that are actually used in your code.

Did the build output improve?

Yes. This is the result of the adjusted configuration and code imports

The size of 13KB might be a bit disappointing. But that is caused by the fact that Webpack still can’t tree-shake everything. But it does not mean every icon will add 13KB of assets to your build. The upfront cost is always slightly higher. But every other module addition will have a way smaller impact. After I added a second icon, the footprint was 16KB which is expected.

So there is a little bit of overhead, but 10KB of overhead is better than 7MB.

Conclusion

And there it is. An example of module sharing that will not force users to download half of the internet when they look at our work.

Some might call using absolute import paths an anti-pattern. And they might be right. But as webpack changed the way we can implement our applications, we will have to accept its design and some hidden limitations that come with it. Using absolute import paths is a small price to pay. Plus the modern build tools can automate most of the work for us.

The bigger problem might be with our dependencies. Some of them might not have the correct build we are looking for to properly share them. If you are interested in how to know if your dependency has the correct build, or you are interested in how the Patternfly modules did it, stay tuned.

--

--

Martin Maroši

A passionate software engineer that loves frontend, pizza, and mountain biking.