In a microfrontend shell, how does clicking a link inside one microfrontend (e.g. a 'View Order' link inside a Cart microfrontend that should open a route owned by a separate Orders microfrontend) trigger a client-side navigation instead of a full page reload?
answer
- single writer for history
- pushState + popstate
- shell owns navigateTo, not each MFE
- unmount old / mount new on route match
- intercept anchor clicks before default
basics
~20 sThe link isn't a normal <a> that reloads the page; the shell's router intercepts the click, updates the browser URL with the History API, notices the new path belongs to a different microfrontend, unmounts the old one, and mounts the new one — all without asking the server for a new page.
solid answer
~50 sCross-microfrontend navigation relies on the shell owning a single top-level router (built on the History API's `pushState`/`replaceState` + a `popstate` listener) that every microfrontend defers to rather than each MFE managing its own top-level navigation. The Cart MFE's 'View Order' link is rendered as an anchor tag with the target href, but a global click interceptor (or a shared navigation helper injected by the shell, e.g. single-spa's `navigateToUrl`) calls `preventDefault()`, calls `pushState` to update the URL, and dispatches a synthetic `popstate`/custom event that the shell's route-matching logic listens for. The shell resolves the new path against its route table, unmounts the Cart MFE if the new path falls outside its territory, and mounts the Orders MFE. The critical constraint is that only the shell (not the MFEs) may call `pushState` directly for top-level routes, or you get two owners of history fighting each other.
go deeper
Should know that a normal <a> click causes a full reload and that microfrontend shells intercept clicks to avoid that; doesn't need History API internals.
Should describe pushState/popstate and the intercept-preventDefault-then-pushState flow, and know unmount/mount happens on the outgoing/incoming MFE.
Should explain why routing must have a single owner (the shell) and identify the common bug pattern of an MFE bypassing the shared navigation helper.
Should discuss this as a platform contract problem — how do you make 'always delegate cross-boundary navigation to the shell' enforceable/discoverable across many independent teams rather than relying on convention, e.g. via linting, a shared navigation library, or route-ownership manifests checked in CI.
## What a client-side cross-navigation preserves **Cross-microfrontend navigation** is the mechanism that lets a link rendered by one independently-deployed microfrontend take the user to a route owned by a completely different, independently-deployed microfrontend, without the browser doing a full page reload — which would defeat much of the point of a single-page microfrontend architecture: - shared state - avoided re-download of the shell/vendor bundle - instant perceived navigation ## The History API underneath Mechanically, the starting point is the browser's History API. `history.pushState(state, title, url)` changes the URL shown in the address bar and adds an entry to the session history stack without triggering a network request or a `load` event. Pair that with a listener on the `popstate` event (fired when the user clicks back/forward, or when something calls `history.back()`/`forward()`), and you have everything needed to build client-side routing: - intercept the intent to navigate - call `pushState` to update the URL - run whatever logic decides what to render for that URL ## Why a cross-boundary link is different In a single microfrontend app, that logic lives entirely inside the app. In a cross-microfrontend scenario, the complication is that the link's origin (the Cart MFE, which rendered `<a href="/orders/123">View Order</a>`) is not the same code that owns the target route (the Orders MFE). If Cart naively let the browser handle a normal anchor click, the browser would issue a full navigation/page load to `/orders/123`, which — depending on server config — either reloads the entire shell from scratch (losing any in-memory state, and re-downloading the shared vendor/shell bundle) or 404s if the server isn't set up to serve the shell for that path too. ## The standard fix — centralize the intent The standard fix is centralizing navigation intent-handling in the shell. Frameworks built for this (single-spa's root config with its `navigateToUrl` helper, or a custom implementation in a Module Federation shell): 1. attach a single global click listener on the document that intercepts clicks on same-origin anchor tags before the browser's default navigation fires; 2. calls `event.preventDefault()`, and instead calls the shell's own `pushState`-wrapping navigation function; 3. that function updates the URL, then re-runs the shell's route-matching against its route table (a data structure mapping path patterns to microfrontend 'owners,' e.g. `/cart/* -> cart-mfe`, `/orders/* -> orders-mfe`); 4. if the new path resolves to a different microfrontend than the one currently mounted, the shell unmounts the outgoing MFE (calling its framework-specific teardown — React's root.unmount(), Vue's `app.unmount()`, or whatever lifecycle hook the microfrontend framework mandates) and mounts the incoming one, injecting whatever route params it parsed from the new path. ## Why routing needs a single writer The reason this must be centralized rather than each MFE managing its own `pushState` calls is that the History API and its `popstate` event are global, per-document, single-writer resources — there's exactly one history stack per browser tab, and if two independently-deployed pieces of code both try to own routing decisions, you get race conditions: - duplicate or missing history entries - back-button behavior that goes to the wrong microfrontend - or two MFEs both trying to mount on the same URL This is the microfrontend-specific version of the general 'single source of truth' principle — for routing, the shell is that source of truth, and individual MFEs should treat top-level navigation as something they request (e.g. by calling a shell-provided `navigateTo(path)` function passed in as a prop/context) rather than something they perform unilaterally. ## The boundary teams get wrong A concrete production pattern: single-spa's root config registers each microfrontend with an `activeWhen` matcher (a path prefix or function) and exposes a shared `navigateToUrl(url)` utility that every registered microfrontend imports and calls for any navigation that might cross an ownership boundary, instead of using framework-native routing (React Router's `<Link>`, Vue Router's `<router-link>`) for those specific cross-boundary links — while still using framework-native routing internally for navigation that stays within one MFE's own territory (e.g. tabs within the Cart page). Getting this boundary right — 'is this a same-MFE navigation I can do myself, or a cross-MFE navigation I must delegate to the shell' — is one of the most common sources of subtle bugs in microfrontend routing: teams forget to route an internal `<Link>` through the shared navigation helper, and it works fine until the target route happens to belong to a different microfrontend, at which point the click either does nothing or does a full page reload.
- What goes wrong if the Cart microfrontend calls `history.pushState` directly for a link into the Orders microfrontend's route space, instead of delegating to the shell's navigation helper?The URL bar changes, but the shell's own route-matching logic never re-runs (because it only reacts to its own navigation calls or `popstate`), so the Orders MFE is never mounted — the user sees the URL change but the Cart MFE stays on screen. This is a very common real bug when a team forgets to route a cross-boundary link through the shared helper.
- Why is a full page reload not just slower but potentially state-losing in a microfrontend cross-navigation?A full reload re-executes the shell's bootstrap from scratch, discarding any in-memory client-side state (auth token cached in memory, a shopping cart held in a shared store, an open WebSocket) that isn't persisted to storage. It also re-downloads the shell and any shared/vendor bundles that a client-side navigation would have kept cached in memory.
- How does the shell decide whether an incoming click is a same-microfrontend or cross-microfrontend navigation?It matches the target href against its route table (a list of path-pattern-to-owning-MFE mappings, e.g. single-spa's activeWhen matchers). If the resolved owner is the currently-mounted microfrontend, no unmount/mount cycle is needed; if it resolves to a different owner, the shell performs the unmount/mount cycle.
It's like an airport with one air-traffic control tower: any plane (link click) that wants to cross into another airline's runway must radio the tower first rather than just taxiing over, or you get two planes trying to land on the same strip.
saying these in an interview costs you the question
- Thinks any pushState call from any MFE 'just works' without shell coordination
- Doesn't mention preventDefault/click interception on anchor tags
- Confuses this with a full page reload and calls that acceptable
- Can't explain why popstate needs a single owner
- No mention of unmount/mount lifecycle for the outgoing/incoming MFE