Synchronizing Browser History Across Micro-Frontend Shells #
When the host shell and one or more remotes each call history.pushState independently, the address bar and the browser back button stop agreeing on where the user actually is. This guide shows how to make the shell the single owner of window.history so remotes read the current route and request navigation through one writer instead of mutating history directly.
The browser exposes exactly one global History object. Every micro-frontend on the page shares it, but most routing libraries assume they own it exclusively. That assumption is what breaks. The fix is structural, not clever: pick one source of truth for the URL, route every write through it, and let everyone else subscribe. This is the practical core of routing coordination across micro-frontends.
Prerequisites #
- A host shell and at least one remote loaded via Module Federation (Webpack 5
^5.75or@module-federation/vite). - A way to share a singleton between host and remotes: a federated
exposesmodule, or a small object placed onwindowduring shell bootstrap. The examples assume the service isshared: { singleton: true }. - TypeScript
^5.0for the typed interfaces below (the runtime is plain JavaScript and works without TS). - All apps mounted on the same origin and document, so they share one
window.history. Cross-origin iframes do not share history and are out of scope here.
Step 1 — Define the shared history service interface #
Every app in the page depends on the same shape. Declare it once as the contract the shell implements and remotes consume.
// shared/history-contract.ts
export interface RouteState {
/** Path including search + hash, e.g. "/orders/42?tab=items" */
url: string;
/** Opaque navigation state, mirrors history.state */
state: Record<string, unknown> | null;
}
export type Unsubscribe = () => void;
export interface ShellHistory {
/** Current route. Always read this — never window.location directly. */
current(): RouteState;
/** Push a new entry (adds to the back stack). */
push(url: string, state?: Record<string, unknown>): void;
/** Replace the current entry (no new back-stack entry). */
replace(url: string, state?: Record<string, unknown>): void;
/** Subscribe to route changes (push, replace, popstate). */
subscribe(listener: (route: RouteState) => void): Unsubscribe;
}
Step 2 — Implement the single writer in the shell #
The shell wraps window.history and becomes the only code that calls pushState/replaceState. It listens to popstate for back/forward and fans every change out to subscribers. A guard collapses duplicate navigations to the same URL so a remote re-asserting its route cannot stack identical entries.
// shell/shell-history.ts
import type { ShellHistory, RouteState, Unsubscribe } from '../shared/history-contract';
function readLocation(): RouteState {
const { pathname, search, hash } = window.location;
return { url: `${pathname}${search}${hash}`, state: window.history.state ?? null };
}
class ShellHistoryService implements ShellHistory {
private listeners = new Set<(r: RouteState) => void>();
private lastUrl = readLocation().url;
constructor() {
// Back/forward buttons fire popstate — the browser already changed the URL.
window.addEventListener('popstate', () => {
this.lastUrl = readLocation().url;
this.emit();
});
}
current(): RouteState {
return readLocation();
}
push(url: string, state: Record<string, unknown> | null = null): void {
if (url === this.lastUrl) return; // guard: collapse duplicate pushes
window.history.pushState(state, '', url);
this.lastUrl = url;
this.emit();
}
replace(url: string, state: Record<string, unknown> | null = null): void {
window.history.replaceState(state, '', url);
this.lastUrl = url;
this.emit();
}
subscribe(listener: (r: RouteState) => void): Unsubscribe {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private emit(): void {
const route = this.current();
this.listeners.forEach((l) => l(route));
}
}
export const shellHistory: ShellHistory = new ShellHistoryService();
The lastUrl comparison in push is the duplicate guard. Without it, two remotes reacting to the same state change can each push the identical URL and pollute the back stack.
Step 3 — Expose the service as a federated singleton #
Make shellHistory available to remotes. With Module Federation, expose it from the host and mark it as a shared singleton so every remote resolves the exact same instance rather than re-importing its own copy.
// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
filename: 'remoteEntry.js',
exposes: {
'./history': './shell/shell-history.ts',
},
shared: {
// Force a single live instance across host + remotes.
'./shell/shell-history.ts': { singleton: true, eager: true },
},
}),
],
};
If you are not on Module Federation, the lightweight equivalent is to assign the instance during shell bootstrap before any remote mounts:
// shell/bootstrap.ts
import { shellHistory } from './shell-history';
(window as unknown as { __shellHistory: typeof shellHistory }).__shellHistory = shellHistory;
Step 4 — Build the remote adapter #
Each remote talks only to the service. It reads the current route on mount, subscribes to future changes, and calls service.push() instead of history.pushState. This adapter is framework-agnostic; below it is wired into a remote’s own router.
// remote/history-adapter.ts
import type { ShellHistory, RouteState, Unsubscribe } from '../shared/history-contract';
// Resolve the shared singleton. Prefer the federated import:
// import { shellHistory } from 'shell/history';
function resolveShellHistory(): ShellHistory {
const fromWindow = (window as unknown as { __shellHistory?: ShellHistory }).__shellHistory;
if (!fromWindow) {
throw new Error('Shell history service not found — bootstrap the shell before mounting remotes.');
}
return fromWindow;
}
export class RemoteHistoryAdapter {
private history: ShellHistory;
private off: Unsubscribe | null = null;
private applying = false; // popstate-loop guard
constructor(history: ShellHistory = resolveShellHistory()) {
this.history = history;
}
/** Read the route the shell currently owns. */
getRoute(): RouteState {
return this.history.current();
}
/** Request navigation. Never call history.pushState from a remote. */
navigate(url: string, state?: Record<string, unknown>): void {
if (this.applying) return; // do not push while reacting to a change
this.history.push(url, state);
}
/** Sync the remote's internal router whenever the shell route changes. */
onRouteChange(apply: (route: RouteState) => void): Unsubscribe {
this.off = this.history.subscribe((route) => {
this.applying = true;
try {
apply(route);
} finally {
this.applying = false;
}
});
return () => this.off?.();
}
}
The applying flag is the popstate-loop guard. When the shell broadcasts a change, the remote updates its internal router; that update must not turn around and call navigate(), which would push again and trigger an infinite loop.
Step 5 — Wire the adapter into a remote router #
Connect the adapter to whatever the remote uses internally. Here a remote keeps a React Router history-like object in sync, but the principle holds for any router: the shell drives the remote, and the remote drives the shell only through navigate().
// remote/RemoteApp.tsx
import { useEffect, useRef, useState } from 'react';
import { RemoteHistoryAdapter } from './history-adapter';
export function RemoteApp() {
const adapter = useRef(new RemoteHistoryAdapter()).current;
const [path, setPath] = useState(() => adapter.getRoute().url);
useEffect(() => {
// Shell -> remote: re-render this remote when the owned route changes.
return adapter.onRouteChange((route) => setPath(route.url));
}, [adapter]);
// Remote -> shell: requests navigation through the single writer.
const goToItem = (id: string) => adapter.navigate(`/orders/${id}?tab=items`);
return (
<section>
<p>Active route: {path}</p>
<button onClick={() => goToItem('42')}>Open order 42</button>
</section>
);
}
For sharing the full router object rather than only history, see sharing a single router instance between host and remotes. If you would rather decouple the request channel entirely, the same navigate-through-the-shell pattern can ride an event bus instead of a direct method call.
Verification #
Confirm the shell is genuinely the only writer and the back stack stays clean.
Back and forward work end to end. Navigate three times across two different remotes, then press the browser back button twice. Each press should restore the previous route and re-render the correct remote. Watch the address bar update in lockstep with the visible UI.
The URL matches the active remote. Add a temporary listener and assert there is no drift between the address bar and the route the shell reports:
shellHistory.subscribe((route) => {
const live = `${location.pathname}${location.search}${location.hash}`;
console.assert(route.url === live, `URL drift: service=${route.url} bar=${live}`);
});
No double entries. Before testing, snapshot history.length. Perform one navigation. Confirm history.length increased by exactly one — not two:
const before = history.length;
shellHistory.push('/orders/99');
console.assert(history.length === before + 1, 'duplicate history entry detected');
Only the shell calls pushState. In DevTools, set a breakpoint or wrap the native method during development to catch any remote that bypasses the service:
const native = History.prototype.pushState;
History.prototype.pushState = function (...args) {
console.trace('pushState called — verify this is the shell history service');
return native.apply(this, args);
};
Any stack trace that does not originate in ShellHistoryService is a leak to fix.
Troubleshooting #
Duplicate history entries after navigation. Symptom: one back press leaves the user on the same visible route. Diagnosis: two writers reached pushState for the same URL — usually a remote still calling history.pushState alongside the service, or the duplicate guard was removed. Fix: route every navigation through service.push(), keep the url === this.lastUrl check, and use the pushState trace above to find the second writer.
Popstate loops or runaway re-renders. Symptom: the page flickers between routes or the tab freezes. Diagnosis: the remote reacts to a shell route change by calling navigate(), which pushes again and re-fires the subscription. Fix: gate outbound navigation with the applying flag (Step 4) so the remote never writes while it is applying an inbound change.
A remote overwrites the host route. Symptom: mounting a remote resets the URL to that remote’s default path. Diagnosis: the remote’s internal router initializes from its own default and pushes on startup instead of reading the shell’s current route. Fix: on mount, call adapter.getRoute() first and hydrate the remote from it; only replace() — never push() — during initialization, and only if the route actually belongs to that remote.
Base path mismatch between shell and remote. Symptom: links resolve to /remote/orders/42 in one app and /orders/42 in another, so back/forward jump to dead routes. Diagnosis: the remote prepends its own base path before handing URLs to the shared writer, or strips a base the shell expects. Fix: standardize on absolute, shell-relative URLs in the contract (the RouteState.url is always the full address-bar path). Normalize at the adapter boundary so the service only ever sees one canonical form.