
Micro frontends team autonomy governance after the split: tokens, Module Federation contracts and one trace
Splitting a frontend gives each team its own deploy. Keeping those teams from drifting apart takes three shared layers, each held in place by a setting you can read and a signal you can check in your own repository.
What does micro frontends team autonomy governance need after the split?#
After the split, teams stay independent only while three layers stay shared and versioned: design tokens and components, the host and remote contract, and one trace per user action. Here the host is the shell app that loads the others, and each remote is a separately built app it loads. Everything else can differ by team. That includes the state library, the test runner and the release day.
The split itself does not decide which layers are shared. Amazon Web Services (AWS) Prescriptive Guidance, in its page on balancing autonomy with alignment, says leaders must "distinguish between areas that can support flexibility" and areas that need a standard. Its list is short. It names release templates, a shared logging library and one way to raise alerts.
So the three layers map onto that list. First, tokens and components keep the screen consistent. Second, the host and remote contract keeps the runtime consistent, through the shared hints in the plugin for webpack, the JavaScript bundler. Third, one telemetry SDK keeps observability consistent across teams. That matters because the micro frontend guide from Honeycomb, a tracing vendor, updated 11 March 2026, says OpenTelemetry "must be initialized only once per page load".
Each layer below gets the same treatment. You get what to version, which setting enforces it, and which signal in your own code shows it slipping.
| Layer | What to version | Setting that enforces it | Drift signal |
|---|---|---|---|
| Tokens and components | Token and component packages under semver | A renamed or removed token is a major release; DTCG aliases and $deprecated | Two majors of the token package in the lockfile |
| Host and remote contract | Module Federation shared hints and generated remote types | singleton, requiredVersion, strictVersion; abortOnError true | A shared entry with no requiredVersion; remote types older than the remote |
| Telemetry | One OpenTelemetry SDK, @opentelemetry/api as a singleton | The host starts the SDK once; a module attribute on every span | Spans with no module attribute |
What does drift cost when nobody owns the shared layers?#
One remote shipping a new React major into a host that never tested it either runs silently with a console warning or throws on first load, and one flag decides which. That flag is strictVersion. In many setups its value was never chosen on purpose.
Take an illustrative case with example version numbers. A host shares React, the user interface (UI) library, as a singleton with requiredVersion ^18.2.0 and ships 18.3.1. Then a checkout team upgrades its remote to React 19.0.0 and deploys on a Tuesday. The host team changed no line of its own code.
If strictVersion is left false, the page keeps one copy of React. It picks the highest version in the shared scope, the pool of modules the builds offer each other, which is the remote's 19.0.0, and the console prints a warning. So the host now runs a major version its tests never saw, in production, because of another team's deploy. But if strictVersion is true, the same deploy throws a runtime error on the first load. As a result, the checkout team finds out before customers do.
Neither outcome is wrong in every case. However, the choice belongs to someone, in writing, with a reason. Otherwise the shared config is a production policy that changes whenever any team edits its own package.json. That is the real cost of unowned shared layers. It is not a slow build but a silent change to code other teams own.
Which layers stay shared, and who owns them?#
Share UI primitives, design tokens, the remote contract and the telemetry SDK; keep domain logic, routes and data inside each team, with a custodian guarding each shared layer. The line between the two is about logic, not about reuse.
Cam Jackson's 2019 article on martinfowler.com draws it plainly. Shared components should "contain only UI logic, and no business or domain logic". So a date picker belongs in the shared library, but a discount rule does not, even when two teams both need it. Once domain models are shared, the same article warns, a breaking change needs a coordinated upgrade across teams.
Ownership is the harder part, and the same article names two ways it fails. If "everyone" owns the shared library, in practice no one does, and it drifts into mixed code. But if one central team writes all of it, the people who build components lose touch with the people who use them.
Instead, the article describes a middle model. Anyone can contribute, and a custodian, a person or a team, is responsible for the quality and consistency of what comes in. Apply that model per layer. For example, the design system team can guard the token package, a platform group the shared config, and whoever runs tracing the telemetry setup. Each custodian reviews changes, and no custodian writes every change.
How should design tokens and the component library be versioned?#
Publish tokens and components as versioned packages, treat a renamed or removed token as a major release, and let each remote adopt new versions on its own schedule. A token file is an API like any other, because remotes build against its names.
Semantic Versioning 2.0.0, the version scheme known as semver, sets the rule. You bump the "MAJOR version when you make incompatible API changes". For tokens, that means a renamed token, a removed token or a changed type. So a new token is a minor release, and a fixed colour value under the same name is a patch.
The token format from the Design Tokens Community Group (DTCG), a community group final report dated 28 October 2025, gives two tools that make majors rarer. First, it supports aliases: "The same value can have multiple names or aliases". So a renamed token can keep its old name pointing at the new one for a release or two. Second, it defines a $deprecated property that marks a token as on its way out, with a reason. Together, they turn most renames into a minor release now and a major one later.
| Token change | Release |
|---|---|
| Renamed token | Major (an alias keeps the old name working for a release or two) |
| Removed token | Major |
| Changed type | Major |
| New token | Minor |
| Fixed colour value under the same name | Patch |
Steve Kinney's course notes on design systems across micro frontends argue for versioned packages first and runtime sharing second. In his view, tokens and primitives compile into each remote at an explicit version. Meanwhile, the shell owns theme choice and the first paint of token variables. That way, a token change never reaches a remote that has not chosen to take it.
What does Module Federation do when two teams need different versions?#
With singleton set and strictVersion left false, Module Federation loads the highest version in the shared scope and only warns; with strictVersion true, the mismatch throws. Without singleton, two versions can live on the page side by side.
webpack's ModuleFederationPlugin page, read in October 2026, defines each hint. The singleton hint "only allows a single version of the shared module in the shared scope". When several versions are on offer, the highest one wins. Next, strictVersion rejects a shared module whose version does not satisfy requiredVersion, and it "Throws a runtime error if the required version is not found". Its default is true only when a local fallback exists and the module is not a singleton. So for a singleton, it is false.
The module-federation.io shared page, read the same month, describes the same runtime from the other side. In singleton mode "a higher version will be loaded if the versions are inconsistent", and the side with the lower version gets a warning. Without singleton mode, each side loads its own copy.
So the hints give three outcomes for one clash:
- singleton, strictVersion false: one copy, the highest version, a console warning.
- singleton, strictVersion true: a runtime error on load.
- no singleton: each build that cannot use the shared copy loads its own fallback, so the page ships both.
The third outcome is fine for a plain utility library. But it breaks React, which keeps global state, as the webpack page notes. Therefore the policy for React and React DOM, its browser renderer, is singleton plus a requiredVersion, and the only open choice is strictVersion. In short, Module Federation shared dependency versioning is a choice per library, not one global switch.
The page runs
19.0.0Console warns
YesThrows on first load
NoCopies downloaded
1The page runs 19.0.0 and only warns
Under singleton the highest version in the shared scope wins, even outside ^18.2.0. The host now runs a version its tests never saw.
| Hints | The page runs | What you see |
|---|---|---|
| singleton, strictVersion false | one copy, the highest version | a console warning |
| singleton, strictVersion true | nothing | a runtime error on load |
| no singleton | both versions | a second copy downloaded |
Outcomes follow webpack's ModuleFederationPlugin page and module-federation.io's shared page, read October 2026. Modelled, not measured.
How does a host get a typed contract for a remote?#
Module Federation's dts option makes each remote emit a zipped type bundle at build time and makes the host download it, so a breaking prop change fails the host's type check. The name comes from the .d.ts type files that TypeScript reads.
On the module-federation.io dts page, read in October 2026, the producer "will automatically generate a compressed type file" called @mf-types.zip during the build. Then the consumer pulls each remote's file and unpacks it into a local @mf-types folder. Both generateTypes and consumeTypes default to true. So a host that imports a remote's component sees its real prop types, not a stub someone typed by hand.
But the defaults hide breakage. When generateTypes or consumeTypes is on, abortOnError is false, and the consumer retries a failed download up to maxRetries, which defaults to 3. In other words, a remote whose types fail to build still deploys. And a host that cannot fetch them still builds against whatever it had before. So the contract exists, but nothing enforces it.
| Setting | Default | What it does |
|---|---|---|
| generateTypes | true | The remote emits @mf-types.zip during the build |
| consumeTypes | true | The host pulls each remote's file into a local @mf-types folder |
| abortOnError | false | Set true on both sides in CI so a broken contract fails the build |
| maxRetries | 3 | Retries for a failed type download |
Set abortOnError to true on both sides in continuous integration (CI), the build that runs on every merge. Then treat the exposed modules as the remote's public API under semver. Removing an exposed module or a required prop is a major release. In short, types are the build-time half of the contract, and the shared hints are the runtime half.
How do traces stay attributable to the team that owns each remote?#
Initialise one OpenTelemetry SDK in the host, share its API as a singleton, and tag every span with the owning remote, so one user action reads as one trace across teams. A span is one timed unit of work inside a trace.
Micro frontends observability across teams usually breaks at startup. Honeycomb's micro frontend guide, updated 11 March 2026, names the failure. OpenTelemetry libraries "must be initialized only once per page load". Module Federation setups hit this most, because every module tries to start its own copy. So the guide's fix is to start the SDK in one central module and list @opentelemetry/api as a singleton in the shared config. Then every remote creates spans through the same API instance.
The Trace Context Recommendation from the W3C, the web's standards body, dated 23 November 2021, defines the trace-id. It is "used to uniquely identify a distributed trace through a system". So one trace id per user action lets a search, a cart update and a payment call read as one story.
Show data table
| Item | Value |
|---|---|
| trace-id | 16 bytes |
| parent-id | 8 bytes |
Every span in one user action carries the same 16-byte trace-id, and each span adds its own 8-byte parent-id.
Still, one trace is not enough on its own, because it has to say which team did what. Honeycomb's guide shows a module attribute set on each span, so an error report points to the remote it came from. Make that attribute a required field in the shared logging schema, which AWS's alignment page also says to standardise.
Which documentation pages do you build these rules from?#
Build the rules from the official pages in this order: the webpack shared hints, the Module Federation shared and dts settings, then the OpenTelemetry browser setup. Each page holds one part of the policy.
- webpack ModuleFederationPlugin: the singleton, requiredVersion and strictVersion hints, and their defaults.
- module-federation.io shared: what singleton and requiredVersion do at runtime when versions differ.
- module-federation.io dts: generateTypes, consumeTypes and abortOnError for typed remotes.
- OpenTelemetry browser getting started, updated 16 October 2026: the TracerProvider, the ZoneContextManager and the W3C trace context propagator.
What is the smallest host config that enforces the shared rules?#
A short block of host configuration declares React and the OpenTelemetry API as strict singletons, turns on remote types, and leaves telemetry setup to the host alone. Your bundler's Module Federation build plugin reads the first file, and the host's entry imports the second file once.
// module-federation.config.ts (host). Version ranges are examples.
import { createModuleFederationConfig } from '@module-federation/enhanced';
export default createModuleFederationConfig({
name: 'host',
remotes: { checkout: 'checkout@https://example.com/checkout/remoteEntry.js' },
shared: {
// 1. One React, and a mismatch throws instead of warning
react: { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
'react-dom': { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
// 2. Every remote creates spans through the host's API instance
'@opentelemetry/api': { singleton: true },
},
// 3. A broken remote contract fails the build
dts: {
generateTypes: { abortOnError: true },
consumeTypes: { abortOnError: true },
},
});
// telemetry.ts (host only), step 4: start the SDK once per page load
import { context, propagation, trace } from '@opentelemetry/api';
import { ConsoleSpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { W3CTraceContextPropagator } from '@opentelemetry/core';
const tracerProvider = new TracerProvider({
spanProcessors: [new SimpleSpanProcessor({ exporter: new ConsoleSpanExporter() })],
});
trace.setGlobalTracerProvider(tracerProvider);
context.setGlobalContextManager(new ZoneContextManager().enable());
propagation.setGlobalPropagator(new W3CTraceContextPropagator());Each remote repeats the shared block with the same keys and turns on generateTypes, and no remote imports telemetry.ts. The console exporter comes from the OpenTelemetry page, so swap it for your tracing backend's exporter. Also, the ZoneContextManager keeps the active trace attached across async callbacks in the browser.
Which signals in your own repository show drift has started?#
Drift shows in the repository before users see it: a shared package installed twice in the lockfile, a shared entry without requiredVersion, stale remote types, spans with no owner. None of these needs a count of teams, and each one checks a line of the config above.
- A shared package installed twice. The lockfile pins every installed version. If it holds two majors of React or of the token package across host and remotes, the shared scope is already picking winners.
- A shared entry with no requiredVersion. webpack's plugin page says the version is inferred from package.json, with a warning when it cannot be found. So an unpinned entry means the policy is whatever package.json says today.
- Remote types older than the remote. If the host's @mf-types folder predates the remote's last release, the build used a stale contract. That is what abortOnError false allows.
- Spans with no module attribute. If some spans in a trace carry no owner, a remote started its own SDK or skipped the schema.
The last signal has a second form. OpenTelemetry's Tracing SDK specification, read in October 2026, sets the batch processor's maxQueueSize default to 2048 spans. "After the size is reached, spans are dropped". Because every remote on the page fills the same queue, one noisy remote can drop another team's spans.
Show data table
| Item | Value |
|---|---|
| maxQueueSize | 2,048 spans |
| maxExportBatchSize | 512 spans |
Every remote on the page fills one 2048-span queue, exported 512 spans at a time.
The timing defaults show why a busy page fills that queue. OpenTelemetry's specification, read in October 2026, sets scheduledDelayMillis to 5000 milliseconds between exports. It also sets exportTimeoutMillis to 30000 before an export is cancelled. So a page that creates spans fast can hit the limit between two exports. When a trace has gaps, check the queue before you blame the remote.
Show data table
| Item | Value |
|---|---|
| scheduledDelayMillis | 5,000 ms |
| exportTimeoutMillis | 30,000 ms |
Spans can wait up to 5000 ms between exports, and an export is cancelled after 30000 ms.
Run the four checks this week. If one hits, it names the layer to fix and the custodian who owns it.
When is this governance the wrong fit for your teams?#
If every remote deploys together or one team owns them all, build-time packages in one repository give the same consistency without runtime version negotiation. The setup above exists for one reason: separate builds that deploy on separate days.
The webpack Module Federation concepts page frames containers as separate builds, where each page "can be separately deployed". When that is not true, simpler tools do the job:
| Situation | Simpler tool |
|---|---|
| Remotes always ship together. | A monorepo, one repository holding every app, with workspace packages gives one lockfile and one React version, checked at build time. So no shared scope is needed. |
| One team owns every remote. | Plain TypeScript project references give typed boundaries without zipped type bundles or download retries. |
| Two remotes share domain models. | No setting fixes that. Jackson's 2019 article says breaking changes to such links need a coordinated upgrade across teams, so merge them or move the boundary. |
And if the real question is whether to split at all, that choice comes first. It is covered in whether micro frontends are worth the complexity.
Where to go next#
Start with the join itself in the Module Federation integration post, then the working host and remote, the strangler migration and the per-remote performance budget. Each one picks up a question these rules leave open.
- How to choose a Module Federation integration strategy covers how the host and remotes are joined.
- Micro frontends with Module Federation builds a working host and remote to apply these settings to.
- Incremental strangler migration to micro frontends covers teams that meet these rules while still mid-migration.
- Module Federation, Core Web Vitals and search ranking sets the performance budget per remote.
If you want a shared token and component library built and owned, see design systems. If you want people to set up shared dependency policy and typed contracts, see frontend software engineers. Still, the official pages linked above are enough to do all of it yourself.