Skip to content

    Frontend System Design

    Cross-App Communication in Micro-Frontends

    Choose how micro-frontends talk: props, custom events, a shared store and BroadcastChannel across tabs, with a typed contract that survives separate releases.

    By Published 16 min read

    • frontend-system-design
    • micro-frontends
    • custom-events
    • broadcastchannel
    • state-sharing
    Share

    Standard lessonintermediateReact labFrontend System Design

    Before this: Micro-Frontend Composition: Build-Time, Runtime, Iframes and the Edge

    What you'll learn: By the end of this lesson you will be able to choose between props and callbacks, custom events, a shared store, postMessage and BroadcastChannel for talking between independently owned front-end pieces, explain why an event is not the same as state (and what a late subscriber misses), keep two browser tabs in step without a message storm, write the small typed contract that lets two teams release without meeting, and run a lab that tests each channel.

    Think of three ways an office passes information. A colleague says "the invoice is approved" out loud: instant, but only people in the room hear it, and anyone who arrives later missed it. A whiteboard holds the current status: anyone can read it at any time, but everyone must agree where it hangs and who may write on it. A notice sent to the office downstairs reaches people who were not in the room, but it arrives later and is a copy.

    Those are custom events, a shared store and a channel between tabs. Micro-frontends use all three, and most trouble comes from using one where another was needed.

    The running example is the storefront from the previous lesson on shared dependencies: a shell, a catalog piece with an "Add to cart" button, and a header piece with a cart badge, owned by different teams. A shopper can also have the store open in two tabs. For the composition options and why the pieces are separate at all, see Monolith vs Micro-Frontends and Micro-Frontend Composition Approaches.

    The core idea

    Every message between two pieces is a public API, owned by someone, and it should be as small as the job allows. Martin Fowler's article on micro-frontends (by Cam Jackson) advises keeping communication between pieces to a minimum, because extra communication tends to reintroduce the coupling that the split removed. It lists passing data and callbacks down from the container, custom events for indirect messaging, and the URL as options, and it aims to avoid shared state, because shared data structures create heavy coupling.

    Three rules follow:

    1. Prefer the narrowest channel that reaches the receiver. A callback prop reaches one child. A DOM event reaches listeners on one page. A BroadcastChannel reaches other tabs. Each step widens the audience and the cost.
    2. Say what happened, do not command. cart:item-added is a fact the catalog team can publish without knowing who listens. header:show-badge is an instruction that ties the catalog to the header's internals.
    3. Events and state are different. An event is a moment that has passed. State is what is true now. Anything a piece needs when it starts (the current cart count) must come from state, not from an event it may have missed.

    Three illustrations run through the lesson, each labelled:

    • Minimal example: a typed event bus on EventTarget and CustomEvent.
    • Realistic example: the cart count shared by catalog and header, and across tabs.
    • Failure examples: a late subscriber that misses an event, two bundled copies of a store, and an echo storm between tabs.

    The channels

    Channel Reaches Delivery Payload Holds state? Main risk
    Props and callbacks from the shell The pieces the shell renders Synchronous call Any value, by reference No Shell must know every piece
    Custom events (EventTarget) Listeners on one page Synchronous Any value, by reference No Late subscribers, hidden listeners
    Shared store (one module instance) Every piece that imports the instance Synchronous notification Any value, by reference Yes Duplicate copies, global mutable state
    URL (path, query) Anything that reads the location When read Strings Yes Length, public by design
    postMessage to an iframe or window One other window A later task Structured clone No Unchecked origin
    BroadcastChannel Other same-origin contexts: tabs, frames, workers A later task Structured clone No Echo loops, lost updates

    The first three live in one JavaScript page and can share objects. The last two cross a boundary where data is copied and delivery is later.

    Figure 1. Where each channel sits

    Pieces on one page talk through events or a store; other tabs are reached through a channelCatalog piece to Event bus (publish); Catalog piece to Shared store (write); Event bus to Header piece (event); Shared store to Header piece (snapshot); Header piece to Tab channel (post); Tab channel to Other tab (copy)ONE TAB, ONE JAVASCRIPT PAGECatalog piece: Add to cartCatalog pieceAdd to cartEvent bus: CustomEventEvent busCustomEventShared store: one instanceShared storeone instanceHeader piece: cart badgeHeader piececart badgeTab channel: BroadcastChannelTab channelBroadcastChannelOther tab: same siteOther tabsame sitepublishwriteeventsnapshotpostcopy

    How to read this: inside the tab, the catalog reaches the header through an event or a store, with no import between the pieces. To reach another tab, a message crosses a channel as a copy and arrives later.

    Custom events on one page

    A DOM event bus needs nothing beyond the browser. EventTarget is the object that has addEventListener and dispatchEvent, and CustomEvent is the standard event type that carries your own data in a detail property (MDN: baseline widely available since July 2015). Pick one target everyone can reach, such as window, and put names and payload types in one shared contract.

    This is the lab's bus, which takes the target as an argument so a test can give each scenario its own:

    export function createEventBus<M extends Record<string, unknown>>(
      target: EventTarget = new EventTarget(),
    ): EventBus<M> {
      return {
        publish(name, detail) {
          // dispatchEvent runs every listener synchronously before it returns.
          target.dispatchEvent(new CustomEvent(name, { detail }));
        },
        subscribe(name, handler) {
          const listener = (event: Event) => handler((event as CustomEvent).detail);
          target.addEventListener(name, listener);
          return () => target.removeEventListener(name, listener);
        },
      };
    }
    
    // The contract: names are prefixed with the owner ("cart:") so two teams cannot clash.
    export type ShopEvents = {
      "cart:item-added": { sku: string; qty: number };
      "cart:cleared": { reason: string };
    };

    What MDN says about dispatchEvent shapes the design. It invokes the affected listeners synchronously and returns only after all of them ran, unlike native events, which the browser queues as tasks. Exceptions thrown by a listener are reported as uncaught exceptions and do not reach the caller, so a broken listener in another team's code never throws into your publish call, and you never see that it failed. The DOM Standard says detail returns the value it was initialized to, so every listener gets the same object the publisher passed, not a copy, and a listener that changes it changes it for every other listener. Treat details as read-only, or publish plain data.

    Three habits keep an event bus healthy:

    • Own the names. A prefix per owning team (cart:, search:) and a TypeScript type in a small shared contract package. The contract package is tiny and versioned, and it is the only thing teams import from each other.
    • Always return the unsubscribe function and call it when a component unmounts, or listeners pile up.
    • Do not hide control flow in events. If piece A needs an answer from piece B before it can continue, an event is the wrong tool. Use a function the shell passes in.

    Events are not state

    An event reaches only the listeners that exist when it is dispatched. A piece that loads later, such as a header lazy-loaded after the first add, never sees the earlier events. The lab shows it in two tests and in its demo:

    === 5. an event is not state: a late subscriber misses what already happened
    store holds 1; late subscriber counted 0
    after the next event: store holds 2; late subscriber counted 1

    The cure is to keep the current value somewhere a late arrival can read it, and to use events to say "this changed". A small store does exactly that.

    A shared store

    React's useSyncExternalStore is the hook for reading data held outside React. You pass subscribe, which registers a callback and returns a cleanup function, and getSnapshot, which returns the current value. React's documentation requires that while the store has not changed, repeated calls to getSnapshot return the same value, and that the snapshot is immutable: return a new object when data changes, otherwise a cached one. Returning a fresh object on every call causes an infinite loop and the error that the result of getSnapshot should be cached.

    The lab's store follows those rules:

    export function createCartStore(initial: CartState = { count: 0, rev: 0, writer: "" }): CartStore {
      let state = initial;
      const listeners = new Set<() => void>();
      const set = (next: CartState) => {
        state = next;
        for (const listener of [...listeners]) listener();
      };
      return {
        getSnapshot: () => state,
        subscribe(listener) {
          listeners.add(listener);
          return () => {
            listeners.delete(listener);
          };
        },
        // ... add, applyRemote and adopt omitted
      };
    }
    export function HeaderStoreApp({ store }: { store: CartStore }) {
      const { count } = useSyncExternalStore(store.subscribe, store.getSnapshot);
      return <span role="status">Cart: {count}</span>;
    }

    Two apps share state only if they import the same instance. If each bundles its own copy of the store module, each has its own state, and nothing connects them. The lab includes a test for exactly that, and it passes by showing the failure: after the catalog's copy records one item, the header's copy still shows 0. The whole question of how to make several bundles use one copy is the subject of Independent Deployment and Shared Dependencies.

    Figure 2. One add-to-cart, through the bus and through the store

    sequenceDiagram accTitle: An add-to-cart seen by a header piece through an event and through a store accDescr: The catalog piece publishes an item-added event. The header, already subscribed, receives it synchronously and updates its count. A header that loads later reads the current count from the store, which the event path cannot give it. participant Ca as Catalog piece participant Bu as Event bus participant St as Shared store participant He as Header piece participant Hl as Header (loads later) Ca->>St: add(1) Ca->>Bu: publish cart:item-added Bu->>He: listener runs (synchronously) He->>St: read snapshot Note over Hl: mounts after the event Hl->>St: read snapshot, current count Note over Hl: the event path alone would show 0

    How to read this: the event tells subscribers that something changed, and the store answers what is true now. A piece that starts late can only use the second.

    Choosing between them. Use the store for values a piece needs on start and on every change (cart count, signed-in user, theme), and events for notifications with no lasting value (a toast request, an analytics hit). Many designs use both: the store holds the value, and its own change notification is the event.

    Across tabs: BroadcastChannel

    The Broadcast Channel API allows basic communication between browsing contexts (windows, tabs, frames, iframes) and workers on the same origin; contexts that create a BroadcastChannel with the same name can send and receive messages (MDN: baseline widely available since March 2022). MDN adds that communication is limited to the same storage partition: storage is partitioned by top-level site, so an iframe from b.com inside a.com cannot talk to a b.com page opened directly. Four rules matter, all from MDN and the HTML Standard:

    • The sender does not hear itself. A message goes to every BroadcastChannel object listening to the channel except the object that sent it.
    • Data is copied with the structured clone algorithm. Functions and DOM nodes throw a DataCloneError.
    • Delivery is a task. Each destination gets the message through a task queued on the DOM manipulation task source, so a message posted now arrives after the current code finishes. A destination closed before its task runs does not receive it.
    • No meaning attached. MDN says the API "doesn't associate any semantics to messages", so you design the message format.

    postMessage on a closed channel throws InvalidStateError. The lab's fake channel models these rules and prints them:

    === 4. what the channel refuses
    post a function:      DataCloneError
    closed before delivery: delivered 0 message(s)
    post after close:     InvalidStateError

    Sending to an iframe or another window uses window.postMessage rather than a named channel. MDN's security guidance is firm: always give an exact targetOrigin, not *, and in the receiver check event.origin (and possibly event.source) before trusting a message, then validate its shape. Failing to check the origin enables cross-site scripting attacks.

    Keeping a cart in step across tabs

    The cart in the lab is a count plus a revision number and the id of the tab that wrote it. A tab broadcasts its state when the user changes it locally, and applies a message it receives only if it is newer (a higher revision, or the same revision from the larger tab id, so all tabs pick the same winner). Messages of an unknown shape are ignored, never thrown on.

    Two mistakes in that code produce the famous echo storm. If a tab broadcasts every change to its store, including the change it just applied from a message, and applies every message without checking whether it is newer, then with three tabs each delivery causes two new messages. Here is the lab's trace of the buggy version, then of the fixed one:

    === 1. one change, three tabs (fixed version)
    after add in tab-1, before delivery: 1@rev1  0@rev0  0@rev0
    after delivery:                      1@rev1  1@rev1  1@rev1
    rounds=1 settled=true delivered=2 queued=0
    
    === 2. the same change with the echo bug (rebroadcast every change, adopt every message)
    round 1: delivered 2, queued for next round 4
    round 2: delivered 4, queued for next round 8
    round 3: delivered 8, queued for next round 16
    round 4: delivered 16, queued for next round 32
    round 5: delivered 32, queued for next round 64
    after 10 rounds in all: settled=false delivered=2046 still queued=2048

    Figure 3. The echo storm and its two guards

    sequenceDiagram accTitle: Echo storm between three tabs and the two guards that stop it accDescr: Tab 1 changes the cart and broadcasts. Tabs 2 and 3 receive it. Without guards each of them applies it and broadcasts again, so every message produces two more. With guards, a received change is not rebroadcast and a message that is not newer is ignored, so the exchange ends after one round. participant T1 as Tab 1 participant T2 as Tab 2 participant T3 as Tab 3 T1->>T2: cart-state rev 1 T1->>T3: cart-state rev 1 Note over T2,T3: bug: apply and broadcast again T2->>T1: cart-state rev 1 T2->>T3: cart-state rev 1 T3->>T1: cart-state rev 1 T3->>T2: cart-state rev 1 Note over T1,T3: fix 1: do not rebroadcast what you received
    fix 2: ignore a message that is not newer

    How to read this: the first two arrows are the real update. Everything after the note is echo. Either guard alone ends the storm, and keeping both also makes the result the same in every tab.

    What last-writer-wins cannot do. If two tabs change at the same moment, both at revision 1, one change is lost: both tabs end at count 1, although two items were added.

    === 3. two tabs change at the same moment (last writer wins)
    two adds happened; both tabs now show: 1@rev1  1@rev1
    winner: tab-2 (one add is lost)

    That is acceptable for a value where only the latest matters, such as a theme choice, and wrong for a counter. Counters and sets need a merge rule. The back-end lesson on CRDTs and conflict resolution covers the data structures that merge instead of overwriting; the other option is to make the server the source of truth and let tabs only signal "refetch".

    A contract that survives releases

    Because pieces release separately, a message must tolerate a sender and receiver of different ages.

    • Name, owner, shape and version in one place. The event name carries the owner prefix; the payload is plain, serialisable data; add a version field once you cross a boundary where copies live longer than one page (a tab left open across a release).
    • Change by adding. New optional fields and new events are safe. Renaming, removing or changing the meaning of a field is a breaking change and needs an overlap period in which both shapes are sent.
    • Receivers validate. Never trust a message because of where it came from. Check the shape, ignore what you do not recognise, and never throw from a handler.
    • Test the contract on both sides. Jackson recommends verifying integrations with focused functional tests or consumer-driven contracts.

    Figure 4. Choosing a channel

    flowchart TD accTitle: Choosing a communication channel between micro-frontends accDescr: First decide whether the receiver is in the same tab. If it is a child the shell renders, pass props and callbacks. If it must start knowing the current value, use a shared store. If it only needs to be told something happened, use a custom event. If it is in another tab or frame, use BroadcastChannel or postMessage with an origin check. Q1{"Is the receiver in
    this tab?"} -->|"no"| Q4{"Another frame
    or another tab?"} Q4 -->|"iframe or window"| P["postMessage with
    exact targetOrigin and origin check"] Q4 -->|"another tab"| B["BroadcastChannel,
    guarded against echo"] Q1 -->|"yes"| Q2{"Does the shell render
    the receiver?"} Q2 -->|"yes"| C["Props and callbacks"] Q2 -->|"no"| Q3{"Must a late starter
    know the current value?"} Q3 -->|"yes"| S["Shared store
    (one instance)"] Q3 -->|"no"| E["Custom event"]

    How to read this: start at the top. Most traffic stays in the left branch (props, a store, an event) and only a few cases, such as cart sync across tabs, need the right one.

    Build it: events, a store and a fake channel

    Goal. Run the catalog and header apps with both an event bus and a shared store, reproduce the late-subscriber and duplicate-copy failures, and watch a cart stay in step across three simulated tabs with and without the echo guards.

    Setup. The lab is in the course's example workspace. It needs Node.js (captured on Node 26.12.1) and pnpm, and uses React 19 with Testing Library under jsdom.

    cd examples/frontend/labs/cross-app-communication
    pnpm test          # 21 tests
    node src/demo.ts   # prints the trace shown in this lesson

    Steps.

    1. Read src/eventBus.ts and src/sharedStore.ts (under 50 lines each), then run the tests. Group A covers events, B the store, C the fake channel, D the cross-tab cart.
    2. Open test/events.test.tsx and find the two tests where the header mounts after the catalog has been used. One uses the event bus and one uses the store. Predict what the late header shows in each ("Cart: 0" or "Cart: 2"), then read the assertions.
    3. Run node src/demo.ts and match each of its five scenarios to a part of this lesson.
    4. In scenario 2, find the round at which the queue passes 50 messages, and explain why it doubles.

    Expected output, captured from a real run (Node 26.12.1, 2026-11-08), is the five blocks shown above, in order:

    === 1. one change, three tabs (fixed version)
    after add in tab-1, before delivery: 1@rev1  0@rev0  0@rev0
    after delivery:                      1@rev1  1@rev1  1@rev1
    rounds=1 settled=true delivered=2 queued=0
    
    === 2. the same change with the echo bug (rebroadcast every change, adopt every message)
    round 1: delivered 2, queued for next round 4
    round 2: delivered 4, queued for next round 8
    round 3: delivered 8, queued for next round 16
    round 4: delivered 16, queued for next round 32
    round 5: delivered 32, queued for next round 64
    after 10 rounds in all: settled=false delivered=2046 still queued=2048
    
    === 3. two tabs change at the same moment (last writer wins)
    two adds happened; both tabs now show: 1@rev1  1@rev1
    winner: tab-2 (one add is lost)
    
    === 4. what the channel refuses
    post a function:      DataCloneError
    closed before delivery: delivered 0 message(s)
    post after close:     InvalidStateError
    
    === 5. an event is not state: a late subscriber misses what already happened
    store holds 1; late subscriber counted 0
    after the next event: store holds 2; late subscriber counted 1

    jsdom is not a browser, and the channel is a fake. The React tests run in jsdom, a JavaScript model of the DOM with no layout, no painting and no BroadcastChannel. The lab's fake models only the rules listed above. It shows nothing about how a real browser orders messages between tabs, how long delivery takes, or what happens across origins.

    Break it. In src/cartSync.ts, delete the line if (options.onlyBroadcastLocalChanges && applyingRemote) return; and make channel.onmessage always call store.adopt(next). Three tests in test/broadcast.test.ts fail and the message count doubles every round. Revert the change.

    Stretch. Add a version field to the message and make the receiver reject a version it does not know; or replace last-writer-wins with a merge that keeps both adds and turn the "known limit" test green with a count of 2.

    In practice: the cart badge across tabs on a storefront

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    The storefront's header piece shows a cart badge, and the catalog piece owns the "Add to cart" button. Today the catalog imports a function from the header's package to update the badge, which forces both teams to release together. Shoppers also keep several tabs open, and the badge in a background tab goes stale: they add an item in one tab and see the old count in the other.

    Starting architecture

    Figure 5. The direct import and the stale second tab

    Starting architecture: catalog calls into the header's package; a second tab never hears about itCatalog piece to Header piece (direct import); Header piece to Second tab (nothing)TAB 1Catalog piece: imports header codeCatalog pieceimports headercodeHeader piece: badge in memoryHeader piecebadge in memorySecond tab: stale badgeSecond tabstale badgedirect importnothing

    How to read this: the warning box is a compile-time dependency between teams. The red edge is the missing path to the other tab.

    Options considered

    Option What it fixes What it costs
    Typed event bus only Removes the import A late-loaded header misses earlier events
    Shared store plus BroadcastChannel Current value on start; other tabs updated One shared instance; echo and conflict guards
    Server is the source of truth, tabs refetch on a signal No conflict logic A request per signal

    The decision

    Field Decision record
    Context The catalog and header teams must release independently; the badge must be right on start and in other tabs.
    Decision One cart store, shared as a singleton by the shell. The catalog writes to it; the header reads it with a snapshot subscription. A sync module broadcasts a versioned { type, from, count, rev } message over a BroadcastChannel for local changes only, applies a message only if it is newer, and ignores unknown shapes.
    Consequences, good No import between pieces; correct on a late start; other tabs update after a task.
    Consequences, bad Concurrent changes in two tabs can lose one add; a shared singleton needs the dependency rules of the previous lesson.
    Revisit when Cart changes must never be lost. Then make the server authoritative and send only "cart changed, refetch" over the channel.

    Rollout and verification

    1. Contract tests first. Publish the message shape in the shared contract package and test it from both sides, including a message of an unknown shape.
    2. Test the sync module against a fake channel: one change settles, a rebroadcast bug fails.
    3. Ship behind a flag and watch message counts per tab, not only errors.
    4. Try two real browsers before release, because a fake proves rules, not browser behaviour.

    The 2 a.m. failure

    A release of the sync module drops the "local changes only" guard in a refactor and no test covers it. In a three-tab session, one add triggers a growing stream of messages. Tabs become slow, fans spin up on laptops, and support receives "the store freezes when I have several tabs open".

    First three checks: (1) open the browser's performance profile in a slow tab and look for a long series of message handlers; (2) count cart-state messages per second per tab in your telemetry; (3) diff the sync module against the previous release for the guard. Mitigation: turn the sync feature flag off, which stops the broadcasts while leaving the in-tab store working. Follow-up: restore the guard, add a test that counts the messages of a three-tab exchange and fails above a bound, and add a per-tab message-rate alert.

    Architect's notes

    • Decide who owns each message before it exists. An unowned event name becomes an API nobody can change.
    • Keep the list of cross-piece messages short. Each one is a coupling. If the list keeps growing, the split may be wrong.
    • Make the server the source of truth when data must not be lost. Cross-tab messages are for speed, not correctness.
    • Never put credentials in a broadcast message. Sharing authentication has its own design, in the lesson on sharing authentication across micro-frontends.

    Common mistakes

    • Using events where a late subscriber needs the current value. It will show an empty state until the next event.
    • Two copies of the store module, one per bundle, so nothing is shared.
    • Using * as the targetOrigin or skipping the event.origin check on postMessage.
    • Rebroadcasting what you just received.
    • Putting a function or a DOM node in a channel message. It throws DataCloneError.

    Key takeaways

    • Treat every cross-piece message as a public API with an owner, a name, a shape and a version.
    • Prefer the narrowest channel: props and callbacks, then a custom event or shared store, then a channel to other tabs.
    • A custom event is synchronous, carries the publisher's object, and is missed by anyone not yet subscribed.
    • Use a store for current values and events for notifications; the store's own change signal is the event.
    • A store is shared only if every bundle imports the same instance.
    • BroadcastChannel copies data, delivers later, never to the sender's own object, and only within the same origin and storage partition.
    • Guard cross-tab sync against echo (do not rebroadcast received changes, ignore stale messages) and decide what to do when two tabs change at once.
    • With postMessage, always set an exact targetOrigin and verify event.origin on receive.

    Practice exercises

    Exercise 1 — Starter (30 min): Run the lab, then add a third event cart:item-removed to ShopEvents and a header handler for it. Write the test that shows the count going down.

    Exercise 2 — Intermediate (2 hours): Add a version field to CartMessage. Make the receiver ignore a higher version it does not know and accept the current one, and test both. Then replace last-writer-wins with a merge that keeps both adds (for instance one counter per tab, summed) and make the "known limit" test return 2.

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments