Synchronizing Redux Across Micro-Frontends #

Redux was designed around a single store owned by a single application. Micro-frontends violate that assumption on day one: you have several independently built, independently deployed apps that load into one browser tab and need to agree on shared state — who is logged in, what the active tenant is, what’s in the cart. The moment two of those apps each createStore(), you have two sources of truth that drift apart, and no amount of careful component code will reconcile them.

This guide is part of Cross-App State & Context Sharing, and it focuses on the hardest version of the problem: keeping Redux consistent across federated boundaries without coupling your teams’ release cadences. We cover the federated store pattern, an action bridge for propagating changes, the edge cases that actually bite in production (hydration races, action validation, version skew), and how to ship the whole thing behind a flag. For two narrower follow-ups, see sharing authentication tokens securely across remote apps and using Zustand for cross-micro-frontend state when you want a lighter store than Redux.

What actually breaks #

The failure modes are specific, and recognizing them early saves you a painful debugging session in production.

Duplicate store instances. If each remote bundles its own copy of @reduxjs/toolkit, the browser ends up with N Redux runtimes. A dispatch in the host updates the host’s store; the remote’s selectors read a different store and never see the change. Symptom: the UI shows stale state in one app while another shows the new value.

Action type collisions. Two teams independently ship a user/login action. When you wire them into one store, a dispatch from team A silently triggers team B’s reducer. Symptom: unrelated slices mutate when an apparently unrelated action fires.

Hydration races. A remote mounts and immediately reads auth.token before the host has finished restoring the session. Symptom: intermittent “undefined token” errors that only reproduce on slow connections or cold loads.

Stale subscriptions. A remote unmounts but its store subscriptions linger, holding component trees in memory and firing on every dispatch. Symptom: a slow memory climb and re-renders of components that should be gone.

Key objectives #

Before touching config, fix the goalposts. A good cross-app Redux setup should:

The architecture #

There are two viable shapes. The first is a single shared store living in the host that remotes inject reducers into — strong consistency, tighter coupling. The second is per-remote stores connected by an action bridge — looser coupling, eventual consistency. Most teams want a hybrid: one shared store for truly global slices (auth, tenant, theme) and isolated stores for domain state, with the bridge forwarding the handful of actions that genuinely need to cross.

Federated store and action bridge The host owns a shared Redux store and an action bridge; two remotes inject reducers and receive only actions marked for cross-boundary propagation. Host shell Shared Redux store Action bridge Remote: Cart cart slice (injected) bridge listener Remote: Profile profile slice (injected) bridge listener marked actions marked actions
The host owns one shared store and an action bridge; remotes inject their own slices and receive only actions explicitly marked for cross-boundary propagation.

Setup and config: the shared store #

The non-negotiable first step is making Redux a singleton across all containers. In Module Federation, that means declaring the Redux packages as shared singletons in every host and remote config — not just the host.

// webpack.config.ts — identical shared block in host AND every remote
import { ModuleFederationPlugin } from 'webpack/lib/container/ModuleFederationPlugin';

const sharedRedux = {
  '@reduxjs/toolkit': { singleton: true, requiredVersion: '^2.0.0', strictVersion: false },
  'react-redux':      { singleton: true, requiredVersion: '^9.0.0', strictVersion: false },
  redux:              { singleton: true, requiredVersion: '^5.0.0', strictVersion: false },
};

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: 'hostApp',
      filename: 'remoteEntry.js',
      shared: sharedRedux,
      exposes: {
        './store': './src/store/index.ts',
        './registerReducer': './src/store/registerReducer.ts',
      },
    }),
  ],
};

singleton: true is what forces a single runtime instance: whichever container loads Redux first wins, and everyone else uses that copy. strictVersion: false lets a remote built against 2.1 run against the host’s 2.3 instead of throwing — it logs a warning rather than crashing the page. The same sharedRedux block must appear in every remote’s config; if even one remote omits it, that remote bundles its own copy and silently desyncs.

The store itself should be created once, in the host, and built to accept reducers it hasn’t seen at build time.

// src/store/index.ts
import { configureStore, combineReducers, type Reducer } from '@reduxjs/toolkit';
import { authReducer } from './slices/auth';

const staticReducers = { auth: authReducer };
const asyncReducers: Record<string, Reducer> = {};

function rootReducer() {
  return combineReducers({ ...staticReducers, ...asyncReducers });
}

export const store = configureStore({
  reducer: rootReducer(),
  middleware: (getDefault) => getDefault().concat(bridgeMiddleware),
});

export function registerReducer(key: string, reducer: Reducer): void {
  if (asyncReducers[key]) return; // idempotent — safe to call on every remount
  asyncReducers[key] = reducer;
  store.replaceReducer(rootReducer());
}

export type RootState = ReturnType<typeof store.getState>;

registerReducer is the contract each remote uses to add its slice without editing a central file. It’s idempotent, so a remote that mounts, unmounts, and remounts won’t corrupt state. This pattern keeps reducer ownership where it belongs — with the team that ships the slice — which is the same decoupling goal behind alternatives to prop drilling in distributed UIs.

Setup and config: a per-domain slice with dynamic injection #

Each remote authors its state as a self-contained Redux Toolkit slice and ships it inside its own bundle. The host never sees the slice at build time — it arrives over Module Federation and registers at mount. Keep the slice namespaced (cart/*) so its action types can never collide with another team’s, and export both the reducer and the actions the remote needs.

// remote-cart/src/slices/cart.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';

interface CartItem { sku: string; qty: number; }
interface CartState { items: CartItem[]; status: 'idle' | 'syncing'; }

const initialState: CartState = { items: [], status: 'idle' };

const cartSlice = createSlice({
  name: 'cart', // becomes the action-type prefix: cart/addItem, cart/teardown
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<CartItem>) {
      const found = state.items.find((i) => i.sku === action.payload.sku);
      if (found) found.qty += action.payload.qty;
      else state.items.push(action.payload);
    },
    teardown() {
      // Returning initialState resets the slice when the remote unmounts.
      return initialState;
    },
  },
});

export const { addItem, teardown } = cartSlice.actions;
export const cartReducer = cartSlice.reducer;

The decisive detail is replaceReducer. When a remote registers, the host rebuilds the root reducer with the new key and swaps it in atomically — existing slice state is preserved, the new branch initializes from its initialState, and every active subscriber recomputes against the next render. That is what lets a remote that didn’t exist when the host booted contribute state at runtime.

Tearing down is the mirror image, and skipping it is the most common leak. On unmount, dispatch the slice’s teardown to reset its data, delete the key from asyncReducers, and replaceReducer again so the branch disappears from the tree entirely.

// src/store/index.ts — companion to registerReducer
export function unregisterReducer(key: string): void {
  if (!asyncReducers[key]) return;
  delete asyncReducers[key];
  store.replaceReducer(rootReducer());
}

Be deliberate about ordering: dispatch teardown before deleting the key, otherwise the action lands on a reducer that no longer exists and silently no-ops. With both halves in place, a remote can mount and unmount any number of times across a session without growing the state tree or leaving orphaned branches behind.

Setup and config: the middleware bridge #

Direct store sharing across boundaries tempts teams into reaching into each other’s slices, which destroys encapsulation. The bridge inverts that: a remote never imports another remote’s store. Instead, a middleware forwards explicitly marked actions onto a transport, and interested remotes subscribe.

// src/store/bridgeMiddleware.ts
import type { Middleware, Action } from '@reduxjs/toolkit';

interface BridgedAction extends Action<string> {
  meta?: { crossApp?: boolean; origin?: string };
}

const ORIGIN = 'host';
const channel = new BroadcastChannel('redux-bridge');

export const bridgeMiddleware: Middleware = () => (next) => (action) => {
  const a = action as BridgedAction;
  // Only forward actions deliberately tagged for the bridge.
  if (a.meta?.crossApp && a.meta?.origin !== '__bridged__') {
    channel.postMessage({ ...a, meta: { ...a.meta, origin: ORIGIN } });
  }
  return next(action);
};

Using BroadcastChannel rather than window events gives you a named, isolable transport with clean teardown, and it reaches across tabs for free. The origin: '__bridged__' tag on the receiving side is what prevents an infinite echo loop — without it, a forwarded action gets re-forwarded forever. If your needs grow beyond marked actions into general pub/sub, the event bus patterns for decoupled apps cover the broader transport question, including the RxJS and CustomEvent variants.

Integration: wiring host and remote #

On the remote side, the integration has two halves: register your reducer with the host store, and attach a bridge listener that replays incoming actions into the same store. Because the store is a singleton, “the host store” and “the remote’s store” are literally the same object.

// remote/src/bootstrap.ts
import { store, registerReducer } from 'hostApp/store';
import { cartReducer } from './slices/cart';

const channel = new BroadcastChannel('redux-bridge');

export function mountCartRemote(): () => void {
  registerReducer('cart', cartReducer);

  const onMessage = (e: MessageEvent) => {
    // Mark as bridged so bridgeMiddleware does not re-broadcast it.
    store.dispatch({ ...e.data, meta: { ...e.data.meta, origin: '__bridged__' } });
  };
  channel.addEventListener('message', onMessage);

  // Return a teardown closure the host calls on unmount.
  return () => channel.removeEventListener('message', onMessage);
}

To dispatch something that should cross the boundary, the producer marks the action:

store.dispatch({ type: 'auth/login', payload: user, meta: { crossApp: true } });

Everything not marked crossApp stays local, which is the default and the safe choice. Reserve cross-boundary propagation for genuinely shared concerns — auth, tenant, feature flags — and keep domain churn inside its own remote. Token-bearing actions like auth/login need extra care so you don’t broadcast a credential to a tab or remote that shouldn’t have it; that’s covered in sharing authentication tokens securely across remote apps.

Edge cases #

Action validation at the boundary #

Anything arriving over BroadcastChannel is untrusted input. A malformed or hostile message can crash a reducer or inject state. Validate the shape before dispatching, and allowlist the action types a remote is willing to accept.

const ACCEPTED = new Set(['auth/login', 'auth/logout', 'tenant/switch']);

function isValidBridged(msg: unknown): msg is { type: string; payload?: unknown } {
  return (
    typeof msg === 'object' &&
    msg !== null &&
    'type' in msg &&
    typeof (msg as { type: unknown }).type === 'string' &&
    ACCEPTED.has((msg as { type: string }).type)
  );
}

Drop anything that fails the check rather than dispatching it. An allowlist also documents, in code, exactly which cross-app actions a remote depends on — which is useful when another team wants to rename one.

The double-store trap when the singleton fails #

Even with singleton: true set everywhere, you can still end up with two stores, and the failure is quiet. The usual causes: one remote pins strictVersion: true against a different major and Module Federation falls back to its own bundled copy; a remote eager-imports @reduxjs/toolkit at the top of remoteEntry.js before the shared scope initializes; or a developer imports the store from a relative path (../host/store) instead of the federated alias (hostApp/store), bypassing federation entirely.

The cheapest guard is a runtime assertion. Stamp the store with a tag at creation and check it on every remote bootstrap.

// src/store/index.ts
(store as { __mfId?: string }).__mfId ??= crypto.randomUUID();

// remote/src/bootstrap.ts
import { store } from 'hostApp/store';
if (!(store as { __mfId?: string }).__mfId) {
  throw new Error('Redux store is not the shared singleton — check the shared block and import path.');
}

If two remotes report different __mfId values in the same tab, the singleton is broken and no amount of bridge wiring will reconcile them. Surfacing it as a hard error in development is far better than chasing stale-state ghosts in production. In a hybrid topology this assertion only applies to the shared store; intentionally isolated per-remote stores will and should have distinct ids.

Hydration races #

The classic bug: a remote mounts and reads auth.token before the host has restored the session from storage. Don’t read shared state optimistically. Gate remote rendering on a readiness signal, and re-run the check whenever shared state changes.

// remote/src/useStoreReady.ts
import { useSyncExternalStore } from 'react';
import { store } from 'hostApp/store';

export function useAuthReady(): boolean {
  return useSyncExternalStore(
    (cb) => { const u = store.subscribe(cb); return u; },
    () => store.getState().auth.status === 'ready',
  );
}

useSyncExternalStore is the correct primitive here: it subscribes and unsubscribes cleanly, avoids tearing during concurrent rendering, and gives you a boolean you can branch on. Render a skeleton while false, the real UI once true. The same subscription discipline kills the stale-subscription leak — when the component unmounts, React tears down the subscription automatically.

When a remote unmounts for good, dispatch a teardown action and drop its slice so reducers and listeners don’t accumulate:

store.dispatch({ type: 'cart/teardown' });
// then remove 'cart' from asyncReducers and replaceReducer

Testing and validation #

You can validate most of this without a running Webpack dev server by mocking the federation runtime and the bridge transport.

  1. Mock the federation globals. Stub __webpack_init_sharing__ and __webpack_share_scopes__ in Jest or Vitest so import('hostApp/store') resolves to your test store. This lets reducer-injection tests run in milliseconds.

  2. Assert singleton behavior. In an integration test, import the store from two simulated remotes and assert they are the same object reference (storeA === storeB). A failure here means a shared block is misconfigured somewhere.

  3. Test the bridge round-trip. Dispatch a crossApp-marked action, capture the BroadcastChannel message, replay it into a second store, and assert the second store’s state matches. Then dispatch an unmarked action and assert nothing crosses.

  4. Test the echo guard. Replay a bridged action and assert it is not re-broadcast — this is the regression test for infinite loops.

  5. Test validation rejection. Send a malformed and a non-allowlisted message and assert neither reaches a reducer.

import { store as a } from './storeFactory';
import { store as b } from './storeFactory';

test('marked actions cross, unmarked do not', () => {
  const channel = new BroadcastChannel('redux-bridge');
  channel.onmessage = (e) => b.dispatch({ ...e.data, meta: { origin: '__bridged__' } });

  a.dispatch({ type: 'auth/login', payload: { id: 1 }, meta: { crossApp: true } });
  expect(b.getState().auth.user?.id).toBe(1);

  a.dispatch({ type: 'cart/add', payload: { sku: 'x' } }); // unmarked
  expect(b.getState().cart?.items ?? []).toHaveLength(0);
});

Deployment #

Independently deployed remotes mean the host and a given remote can be on different versions at any instant. Build for that, and make the synchronization layer reversible.

Flag the bridge. Wrap bridge activation behind a runtime feature flag (ENABLE_REDUX_BRIDGE) read from config, not a build constant. If desync rates spike after a release, you disable forwarding without redeploying — local state keeps working, only cross-app sync pauses.

Cache remoteEntry.js correctly. Serve the manifest with Cache-Control: no-cache while letting hashed chunks cache forever. Otherwise a CDN can pin you to an old remote that emits a stale action schema.

Guard versions in CI. Run npm ls @reduxjs/toolkit react-redux redux across every micro-frontend repo and fail the pipeline on a major mismatch. Minor and patch differences are fine because strictVersion: false resolves them at runtime; majors are the ones that break the action contract.

Migrate schemas with dual-write. When a shared action’s payload shape must change, keep the old reducer handling the old shape while the new one lands, dispatch both shapes during a transition window, then retire the legacy slice once telemetry confirms no consumer still reads it. Tag bridged actions with a schemaVersion in meta so receivers can branch during the overlap.

Watch it. Emit a metric every time a bridged action is dropped by validation or rejected by version check. A rising drop rate is your earliest signal that two teams’ contracts have drifted apart.

Common pitfalls #

Issue Root cause & resolution
State updates in one app, not another A remote bundled its own Redux instead of sharing the singleton. Verify every host and remote config has the identical shared block with singleton: true.
Unrelated slices mutate on a dispatch Action type collision between teams. Namespace every type (cart/add, not add) and only forward allowlisted types across the bridge.
Infinite re-broadcast loop Bridged actions get re-forwarded by the middleware. Tag incoming actions with origin: '__bridged__' and skip them in the forwarding check.
Intermittent “undefined token” on cold load Hydration race — a remote read shared state before the host restored it. Gate rendering on a readiness selector via useSyncExternalStore.
Memory climbs over a session Stale subscriptions and reducers from unmounted remotes. Return a teardown closure on mount, remove the async reducer, and replaceReducer.
Page crashes after a remote deploy A strict version check rejected a minor skew. Set strictVersion: false and gate only major mismatches in CI.
Two remotes show different state with identical config The singleton silently fell back to a bundled copy — eager import, strictVersion: true, or a relative store import. Add the __mfId runtime assertion to catch it at mount.
A teardown dispatch does nothing The async reducer was deleted before the action was dispatched. Dispatch teardown first, then unregisterReducer and replaceReducer.
New slice state is undefined after mount The reducer was registered but replaceReducer wasn’t called, so the root reducer never picked up the key. Ensure registerReducer always swaps the reducer.
RTK Query double-fetches the same endpoint Each remote created its own api instance. Share one api slice through the host store and tune refetchOnMountOrArgChange.

FAQ #

Should I use one shared store or a store per remote?

Use one shared store for genuinely global, tightly coupled state — auth, tenant, theme — and let remotes keep isolated stores or local slices for their own domain. The bridge then forwards only the small set of actions that must cross. A single store for everything recouples your release cadence; a store per remote with no bridge gives you drift. The hybrid is almost always right.

How do version mismatches between independently deployed remotes resolve?

With singleton: true and strictVersion: false, the first-loaded compatible version wins and everyone shares it; a minor or patch skew logs a warning instead of crashing. Only major version differences are dangerous, because they can change the action or reducer contract — gate those in CI and resolve them with a coordinated upgrade.

Can RTK Query be shared across remotes?

Yes, but share the single api slice instance through the host store rather than letting each remote create its own. Configure refetchOnMountOrArgChange deliberately so two remotes mounting the same endpoint don’t each trigger a fetch. Treat the cache as shared global state, not per-remote.

Is Redux overkill for this? When should I reach for something lighter?

If your shared surface is a handful of values and a few actions, a smaller store is easier to federate and reason about — see using Zustand for cross-micro-frontend state. Stay with Redux when you already have substantial reducer logic, middleware, or RTK Query investment that you want consistent across apps.

Do I need the action bridge if everything already shares one singleton store?

No — if every remote dispatches into and selects from the same singleton, state is already consistent in that tab without any forwarding. The bridge earns its keep in two situations: a hybrid topology where some remotes keep isolated stores and only a few actions must cross into them, and multi-tab synchronization, where BroadcastChannel propagates a marked action (a logout, say) to every open tab. If you have a single store and a single tab, skip the bridge entirely.

Where do Redux Toolkit listeners and thunks fit across the boundary?

createListenerMiddleware and async thunks run wherever the store lives, which means once the store is a singleton they execute exactly once regardless of how many remotes are mounted — so register listeners in the host, not in each remote, or you’ll attach duplicates. Thunks dispatched from a remote resolve against the shared store and their lifecycle actions (pending/fulfilled/rejected) are namespaced by their type prefix, so they cross the bridge only if you explicitly mark them. Keep heavy domain effects inside the owning remote and reserve the host for genuinely global orchestration.