Meet Modi
Back to Blog
·6 min·updated

A payment in one iframe and three panels that never heard about it

Three embedded surfaces, three documents, three private copies of 'is this user subscribed'. Nothing in the browser tells a sibling frame that state moved.

By Meet Modi
Micro-FrontendsPaymentsState ManagementFrontend ArchitectureDOM

Each iframe is a separate document with its own JavaScript heap, so entitlement state fetched in one is invisible to the others and nothing in the browser notifies them when it changes. Pay inside one panel and the panel beside it keeps refusing. The fix is to route the change through the host as a hub rather than have the panels gossip, so no surface needs to know which others are on screen.

Three places to buy the same thing

A document page embeds three independent surfaces, each its own iframe, and a subscription can be bought from all three: the paywall's own frame, the upgrade screen inside the chat panel, and the one inside the practice panel.

Each keeps its own answer to "is this user subscribed", and each fetches it from a different endpoint, because each is authoritative for a different thing. One reads a limits endpoint, one a usage snapshot, one a payments slice in its own store.

So: pay at the paywall, and the page unlocks. The chat panel beside it, which the student was using thirty seconds earlier, keeps showing the limit screen. The reverse leaked too. A payment taken inside the chat panel was announced, but only the payment modal was listening, on its own frame id, so nobody heard it.

Why nothing propagates on its own

An iframe is a separate document with a separate global. There is no shared memory, no shared store, and no event that fires in frame B because frame A fetched something. Same-origin frames can reach each other through window.parent and window.frames, but only if someone writes the code to do it.

BroadcastChannel is the obvious candidate and it genuinely does work for same-origin frames. It did not fit here: the panels are not all same-origin, and the host already had a bridge carrying every other cross-frame concern. A second mechanism would have meant two ways to do one thing.

Hub, not gossip

The design decision worth stealing is where the fan-out lives. Panels talking to each other directly is O(n²) wiring, and each panel has to know which others exist, which it cannot, because what is on screen depends on the route and the viewport.

A panel takes a payment and sends notify payment_success to the host. The host fans execute subscriptionChanged out to all three panels, including the one that took the payment, and each refetches from its own authority.
The payer is included in the broadcast on purpose: excluding it would mean identifying the sender and giving every panel a second code path.
// The host mounts this once, above the routes.
function SubscriptionSync() {
  useEffect(() => {
    const onMessage = (e: MessageEvent) => {
      if (!ALLOWED_ORIGINS.has(e.origin)) return;
      if (parse(e.data)?.notify !== "payment_success") return;

      // 1. Our own copy first: every lock in the host unmounts off this.
      refreshSubscription();
      // 2. Then tell every panel, the payer included, so no surface has
      //    to know which others are on screen.
      broadcastToIframes({ action: "execute", what: "subscriptionChanged" });
    };
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []);
  return null;
}

A panel therefore sends nothing new. It already announced its own successful payment, because it had to for its own modal. All it gains is a listener.

Broadcast to the payer too

Including the frame that took the payment looks redundant and is what makes the whole thing simple. If the payer is excluded, the host has to identify which frame sent the notification and exclude exactly that one, and every panel needs a second code path for "I was the payer". Including it means one message, one handler, one path.

It costs one extra refetch of data the payer was going to refetch anyway.

Each surface refetches its own authority

The broadcast carries no payload. It says the subscription changed, not what it changed to, and each panel answers by refetching from whichever endpoint is authoritative for it.

Shipping the new state in the message would mean the host asserting a fact about data it does not own, and three surfaces trusting a value that did not come from their own backend. The refetch is one request and it keeps every surface the authority on itself.

Two bridge primitives this needed

  • A broadcast that reaches every ready frame without triggering the panel-open callback that a targeted send does. Reusing the targeted send would have popped panels open on payment.
  • A single owner for the wire contract, so the string subscriptionChanged is written in exactly one place. Four surfaces independently typing the same literal is a typo waiting to be a silent no-op.

Same family as three ways for a payment to finish, except the thing I missed was not an exit from the flow but every surface that was not looking at it.

Embedded surfaces go wrong in the other direction too, where the state is missing rather than stale: a Redux slice only one bootstrap path registered.

More Posts