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,
postMessageandBroadcastChannelfor 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:
- Prefer the narrowest channel that reaches the receiver. A callback prop reaches one child. A DOM event reaches listeners on one page. A
BroadcastChannelreaches other tabs. Each step widens the audience and the cost. - Say what happened, do not command.
cart:item-addedis a fact the catalog team can publish without knowing who listens.header:show-badgeis an instruction that ties the catalog to the header's internals. - 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
EventTargetandCustomEvent. - 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
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
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
BroadcastChannelobject 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
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
versionfield 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
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.
- Read
src/eventBus.tsandsrc/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. - Open
test/events.test.tsxand 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. - Run
node src/demo.tsand match each of its five scenarios to a part of this lesson. - 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
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
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
- 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.
- Test the sync module against a fake channel: one change settles, a rebroadcast bug fails.
- Ship behind a flag and watch message counts per tab, not only errors.
- 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 thetargetOriginor skipping theevent.origincheck onpostMessage. - 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.
BroadcastChannelcopies 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 exacttargetOriginand verifyevent.originon 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.
Related reading
In this course:
- Independent Deployment and Shared Dependencies, how to make bundles share one store instance.
- Monolith vs Micro-Frontends and Micro-Frontend Composition Approaches.
- The Event Loop and the Main Thread, why cross-tab delivery is a later task.
- Same-Origin Policy and CORS, what an origin is.
Elsewhere on the site:
- Backend: CRDTs and conflict resolution, merging concurrent changes instead of overwriting.