A shell app mounts a microfrontend that has its own internal React Router instance for sub-navigation (e.g. tabs within `/account/*`). What specifically needs to be synchronized between the shell's top-level router and the microfrontend's internal router so that browser back/forward and deep-linking both work correctly?
answer
- one History object, one popstate stream
- basename scoping = MFE prefix
- shell decides cross-boundary, MFE router decides in-boundary
- unmount must remove popstate listeners
- pushState vs replaceState for back-stack granularity
basics
~20 sThe microfrontend's internal router must not fight the shell's top-level router over control of the browser's URL and back button — usually the MFE's router is scoped to only the part of the path under its own prefix, and both must agree on one shared history stack instead of each keeping a separate idea of 'where we are.'
solid answer
~50 sThere's exactly one browser history stack, so the shell's top-level router and the microfrontend's internal router must treat it as a single shared resource rather than each independently calling pushState. In practice this means: (1) the MFE's router is configured with a `basename`/base path equal to the shell's route prefix for that MFE, so its internal paths are relative and don't collide with the shell's top-level matching; (2) only one of the two actually owns the decision for each transition — the shell decides cross-boundary transitions, while the MFE's own router reacts to the same shared popstate/pushState events for in-boundary matches; (3) when the shell unmounts the MFE, the MFE's router must be properly torn down (listeners removed) so it doesn't keep reacting to future popstate events for URLs it no longer owns — a classic listener leak if forgotten.
go deeper
Should understand there's only one browser back button / history stack shared by the whole app, even though it feels like separate apps are mounted.
Should describe basename scoping and know that unmounting a microfrontend must also remove its history-related listeners.
Should reason through failure modes (listener leaks, history-granularity mismatches) and know pushState vs replaceState as a mitigation tool.
Should be able to design the contract between shell and microfrontends for navigation ownership — who's allowed to call pushState directly, how boundary crossings are detected, and how to make listener cleanup a structurally enforced part of the mount/unmount lifecycle contract rather than a per-team discipline.
## One history stack, two routers When a mounted microfrontend has its own internal routing (e.g., a React Router `<Routes>` tree managing tabs or wizard steps within `/account/*`), the shell's top-level router and the microfrontend's internal router are not two independent routing systems — they must cooperate over exactly one shared resource: the browser's single History API stack (`window.history`) and the single `popstate` event stream. Getting this synchronization wrong is one of the most common sources of subtle, hard-to-reproduce bugs in microfrontend architectures, because it only manifests on specific interaction sequences: - back button after a deep sub-navigation - refresh on a nested URL - forward after leaving and re-entering the MFE ## Base path scoping The mechanism that makes this work correctly starts with **base path scoping**. The microfrontend's internal router is configured with a `basename` (React Router's term) or equivalent base path equal to the URL prefix the shell has assigned it — e.g. if the shell mounts the Account MFE for anything under `/account/*`, the Account MFE's internal router is told its basename is `/account`, so when its own code calls `navigate('/billing')` internally, the router computes the actual browser URL as `/account/billing`, not just `/billing`. This scoping is what prevents the classic bug where a microfrontend, built and tested standalone, assumes it's mounted at the domain root and clobbers the shell's URL structure once integrated. ## Which router actually calls pushState The second piece is which router actually calls pushState/handles popstate. Two workable models exist. 1. **In the first** (more common with frameworks like single-spa), the shell only decides top-level transitions — which MFE should be mounted for the current path — and once a microfrontend is mounted, its own internal router is a normal, unmodified instance of whatever the framework provides (React Router, Vue Router), which itself listens to the same global `popstate` event and calls the same global `history.pushState`, because there is genuinely only one browser History object; there's no need for the shell to proxy every call, only to decide, on each `popstate`/navigation event, whether the new path is still within the currently-mounted MFE's boundary (in which case it lets the MFE's own router handle the re-render) or has crossed into a different MFE's territory (in which case the shell unmounts the current one and mounts the new one). 2. **In the second model**, used when the shell needs tighter control (e.g., to guarantee analytics events fire on every transition), the shell wraps `pushState`/`popstate` in its own abstraction and each microfrontend is required to route all navigation, even internal, through a shell-provided hook. ## Failure mode — listener leakage across unmount The critical synchronization failure mode is listener leakage across unmount. When the user navigates away from `/account/*` to, say, `/search/*`, the shell unmounts the Account MFE. If the Account MFE's internal router's `popstate` listener isn't properly removed as part of that teardown (a framework's router typically does this automatically on unmount, but a hand-rolled router or an MFE with a bug in its lifecycle hook might not), that listener keeps firing on every subsequent `popstate` event even though its component tree is gone: - at best a silent memory leak - at worst it tries to call `setState` on unmounted components (React warnings) - or, in a bad case, actually re-renders stale account UI in response to navigation events that have nothing to do with it, because it never got the memo that it's no longer the owner of the current URL ## Failure mode — history-entry granularity A second failure mode is history-entry granularity mismatch: if the internal router uses `pushState` for every minor UI change (e.g., every tab switch, every wizard step) without the team thinking about it, the back button becomes tedious — pressing back once takes the user through five internal tab-switch history entries before finally leaving the MFE, which is technically correct (every URL change is a real, bookmarkable state) but a poor UX if those intermediate states weren't meant to be independently navigable. The standard mitigation is deliberately choosing `replaceState` instead of `pushState` for UI transitions that shouldn't create a new back-stack entry (e.g., ephemeral filter changes) while reserving `pushState` for genuinely distinct, shareable views. ## A concrete scenario A concrete real scenario: a checkout microfrontend with an internal multi-step wizard (shipping -> payment -> review) built with React Router nested routes, mounted under `/checkout/*` by a single-spa shell. QA reports that after completing checkout and navigating to `/orders`, pressing the browser back button shows a blank white screen instead of the review step. Root cause, commonly: the checkout MFE's router wasn't unmounted cleanly (a `useEffect` cleanup was missing), so its component tree tried to render against a `popstate`-triggered URL of `/orders` that its own route table doesn't recognize, producing a blank fallback instead of correctly deferring back to the shell to mount the Orders MFE.
- Why does a microfrontend's internal router need a basename/base path matching the shell's assigned URL prefix?Without it, the internal router computes navigation targets relative to the domain root instead of its actual mount point, so a call like `navigate('/billing')` would produce `/billing` instead of the correct `/account/billing`, silently breaking or colliding with the shell's top-level route table.
- What's a concrete symptom of a microfrontend not cleaning up its popstate listener on unmount?After navigating away to a different microfrontend and then using browser back/forward, the old microfrontend's stale UI can briefly flash, attempt to re-render on top of the new one, or throw framework warnings about updating state on an unmounted component, because its listener is still reacting to history events for URLs it no longer owns.
- When should a microfrontend's internal navigation use replaceState instead of pushState?For transient UI state changes that shouldn't be independently reachable via back/forward — like a filter toggle, an accordion open/close, or an intermediate loading state — replaceState updates the URL without adding a new back-stack entry, keeping the back button meaningful for genuinely distinct views.
It's like a relay race with one baton (the browser history) — the shell and the microfrontend's own router must hand it off cleanly at the boundary; if the outgoing runner doesn't let go (doesn't unmount its listener), you get two runners pulling on the same baton.
saying these in an interview costs you the question
- Thinks each router can independently manage its own separate 'virtual' history
- Doesn't mention basename/base-path scoping
- No awareness that a stale listener after unmount is a real bug pattern
- Confuses pushState/replaceState semantics
- Assumes the shell must proxy every single internal navigation call