Backward-Compatible Remote API Contracts #
A remote can ship a new version at any moment, but the hosts that consume it deploy on their own schedule — so every change to a remote’s exposed API must keep working for hosts that were built against the previous version.
The contract a remote exposes is wider than most teams assume. It includes the props of every exposed component, the shape of every exposed module (functions, classes, return types), and the payload of every event the remote emits. Break any one of these and an already-deployed host crashes the moment it loads the new remoteEntry.js. This guide shows how to evolve that surface additively, with safe defaults, deprecation windows, tolerant readers, and adapter shims, so old and new hosts both keep working.
This is the runtime-safety companion to Semantic Versioning for Module Federation Remotes: SemVer tells consumers whether a change is breaking, while backward compatibility is the discipline that lets you keep bumping the minor version instead of the major one.
Prerequisites #
- A remote built with Webpack 5 (
ModuleFederationPlugin) or@module-federation/vite, exposing at least one React component viaexposes. - TypeScript 5.x on both host and remote. Types catch some breaks at build time, but the host builds against its own copy of the remote’s
.d.ts— so a runtime break can ship even when the host compiled cleanly. - A host that imports the remote with a dynamic
import('remote/Widget')and renders it. - A shared understanding of which version ranges are in production. Pair this with contract testing between frontend teams so compatibility is enforced in CI, not discovered in incidents.
Step 1: Make every change additive #
The single rule that prevents most breaks: never remove or rename anything in the same release that introduces a replacement. Add the new thing, leave the old thing in place.
A new prop must be optional and must carry a safe default that reproduces the old behavior. A host that knows nothing about the prop omits it, and the default keeps the component behaving exactly as v1 did.
// remote/src/Widget.tsx — v2, additive only
import { useState } from 'react';
export type Density = 'cozy' | 'compact';
export interface WidgetProps {
title: string;
// NEW in v2: optional, with a default that matches v1 behavior.
density?: Density;
}
export function Widget({ title, density = 'cozy' }: WidgetProps) {
const [open, setOpen] = useState(false);
return (
<section data-density={density} onClick={() => setOpen(o => !o)}>
<h3>{title}</h3>
{open && <p>Details for {title}</p>}
</section>
);
}
An old host renders <Widget title="Reports" />. Because density defaults to 'cozy', nothing changes for it. A new host can opt into <Widget title="Reports" density="compact" />.
Step 2: Deprecate instead of deleting #
When you genuinely need to retire a prop, keep it working through a deprecation window — typically one or two minor releases, long enough for every host to redeploy. During the window, accept both the old and new prop and map one onto the other.
// remote/src/Widget.tsx — renaming `title` to `heading` compatibly
export interface WidgetProps {
/** @deprecated since v2.1 — use `heading`. Removed in v3. */
title?: string;
heading?: string;
density?: Density;
}
export function Widget({ title, heading, density = 'cozy' }: WidgetProps) {
// Prefer the new name; fall back to the deprecated one.
const resolvedHeading = heading ?? title ?? '';
if (title && !heading && process.env.NODE_ENV !== 'production') {
console.warn('[Widget] `title` is deprecated; use `heading`. Removed in v3.');
}
return <section data-density={density}><h3>{resolvedHeading}</h3></section>;
}
The @deprecated JSDoc tag surfaces a strikethrough in editors for host developers, and the runtime warning catches hosts that never recompiled. Only after telemetry confirms no host still sends title do you remove it — in a major version, per Semantic Versioning for Module Federation Remotes.
Step 3: Write tolerant readers for events #
Events flow the other direction — the remote emits, the host listens — so the remote owns the payload contract. The host must be a tolerant reader: it consumes the fields it knows and ignores everything else. That lets the remote add fields to a payload without breaking any listener.
// remote/src/Widget.tsx — emit a structured, versioned event
export interface SelectDetailV2 {
// v1 field — never change its meaning or type.
label: string;
// NEW in v2 — additive field.
id?: string;
}
function emitSelect(node: HTMLElement, detail: SelectDetailV2) {
node.dispatchEvent(
new CustomEvent('widget:select', { detail, bubbles: true, composed: true }),
);
}
// host/src/listen.ts — tolerant reader, ignores unknown fields
window.addEventListener('widget:select', (e) => {
const detail = (e as CustomEvent).detail ?? {};
const label = typeof detail.label === 'string' ? detail.label : '(unknown)';
// We don't read `id`; a future field we don't know about is harmless.
trackSelection(label);
});
The host validates the fields it needs and supplies a fallback rather than assuming the payload shape. Adding id, or any later field, never breaks it. The inverse is also true: never repurpose an existing field’s type or meaning, because that is a break even though the field name is unchanged.
Step 4: Add an adapter for unavoidable renames #
When a host you can’t redeploy depends on an old shape, wrap the new component in a thin adapter exposed under the old name. The adapter translates the legacy API into the new one, so the old host keeps importing the path it always did.
// remote/src/WidgetLegacy.tsx — adapter exposed as the old module
import { Widget, type Density } from './Widget';
interface LegacyProps {
title: string;
// old boolean prop that v2 replaced with the `density` enum
compact?: boolean;
}
export function WidgetLegacy({ title, compact }: LegacyProps) {
const density: Density = compact ? 'compact' : 'cozy';
return <Widget heading={title} density={density} />;
}
// remote/webpack.config.js — expose both the new and legacy modules
new ModuleFederationPlugin({
name: 'remote',
filename: 'remoteEntry.js',
exposes: {
'./Widget': './src/Widget', // new API for new hosts
'./WidgetLegacy': './src/WidgetLegacy', // shim for old hosts
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});
Old hosts that import remote/WidgetLegacy keep working unchanged; new hosts adopt remote/Widget. The adapter is the bridge that buys you the deprecation window without forcing a synchronized deploy.
Step 5: Feature-detect new capabilities from the host #
When a host wants to use a capability that only newer remotes have, it must not assume the capability exists — it may be loading an older remote in some environments. Detect the capability at runtime and degrade gracefully.
// host/src/useRemoteCapability.ts
import { lazy } from 'react';
const Widget = lazy(() => import('remote/Widget'));
export async function supportsDensity(): Promise<boolean> {
const mod = await import('remote/Widget');
// The remote exposes a capabilities marker alongside the component.
return (mod as { CAPABILITIES?: string[] }).CAPABILITIES?.includes('density') ?? false;
}
export { Widget };
// remote/src/Widget.tsx — advertise capabilities
export const CAPABILITIES = ['density', 'select-id'] as const;
Exporting a CAPABILITIES array gives hosts a stable, additive way to ask “does this version support X?” without parsing version strings. A host renders the compact UI only when supportsDensity() resolves true, and falls back otherwise.
Verification: old host against new remote #
The decisive test is loading an old host build against the new remote and confirming it still renders. Pin the host’s federation config to the new remoteEntry.js and run the host’s existing integration suite unchanged.
// host/test/compat.test.tsx — run the v1 host's assertions against remote v2
import { render, screen } from '@testing-library/react';
import { Suspense, lazy } from 'react';
const Widget = lazy(() => import('remote/Widget'));
test('v1 host usage still renders against v2 remote', async () => {
// Exactly how the old host called it — no new props.
render(
<Suspense fallback={null}>
<Widget title="Reports" />
</Suspense>,
);
expect(await screen.findByText('Reports')).toBeInTheDocument();
});
In the browser, open DevTools and confirm: the Console shows no React prop-type or “is not a function” errors, the Network tab loads the new remoteEntry.js (check the hash), and the component renders its default (data-density="cozy") when no new prop is passed. A green run here is your go-signal to publish.
Troubleshooting #
Symptom: old host throws after the remote upgrades, and a required new prop is undefined.
Diagnosis: you added a prop without a default, or marked it required, so the old host renders with undefined and the component dereferences it. Fix: make the prop optional and give it a default that reproduces v1 behavior (density = 'cozy'). Never introduce a required prop in a minor release — a required prop is always a breaking change.
Symptom: the host’s event handler stopped firing after the remote update.
Diagnosis: the remote renamed the event (widget:select → widget:choose) or moved a field the host read at the top level. Fix: keep emitting the old event name through a deprecation window, or emit both; and never relocate or retype an existing payload field. Make the host a tolerant reader so additive fields can’t break it.
Symptom: the old host crashes on a value it has never seen in a switch or mapping.
Diagnosis: the remote added a new enum value (a third density, or a new event type) and the old host has no branch for it, hitting an unhandled default that throws. Fix: hosts should treat enums open-endedly — fall through to a safe default rather than throwing — and remotes should document that new enum members are additive. This is exactly the kind of contract drift that contract testing between frontend teams is designed to catch before release.
Symptom: TypeScript compiled clean but production still broke at runtime.
Diagnosis: the host built against its own bundled copy of the remote’s .d.ts, so a runtime-only change (a renamed exposed module, a property that’s now null at runtime, a default that changed) was invisible to the compiler. Fix: don’t rely on types as your only safety net. Add the old-host-against-new-remote integration test above, and keep the runtime console.warn deprecation signals so drift is observable even when the type checker is silent. The broader version-negotiation safeguards in Versioning Strategies for Remote Apps cover the build-time side of this.