React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

postMessageMessageChanneltransferablemicro-frontendRPCbrowser-apis

Advanced postMessage Patterns — MessageChannel, transferables, RPC layers, and micro-frontend architecture

Recipe

// Create a dedicated MessageChannel between parent and iframe
const channel = new MessageChannel();
 
// Transfer port2 to the iframe via postMessage
iframeRef.current?.contentWindow?.postMessage(
  { type: "INIT_CHANNEL" },
  "https://widget.example.com",
  [channel.port2] // Transfer the port
);
 
// Parent communicates via port1 — no more origin checks needed
channel.port1.onmessage = (event: MessageEvent) => {
  console.log("From iframe (via channel):", event.data);
};
channel.port1.postMessage({ type: "HELLO" });

When to reach for this: When you need dedicated communication channels between specific iframe pairs, zero-copy data transfer for large payloads, a clean RPC abstraction over postMessage, or a full micro-frontend shell that coordinates multiple independent applications.

Working Example

A micro-frontend shell loads two independent React apps in iframes, shares auth state, and coordinates routing between them.

Shell Types

// types/shell.ts — shared across all micro-frontends
 
export type ShellToApp =
  | { type: "AUTH_STATE"; payload: AuthState }
  | { type: "ROUTE_CHANGE"; payload: { path: string; params: Record<string, string> } }
  | { type: "THEME_CHANGE"; payload: ThemeConfig }
  | { type: "SHUTDOWN" };
 
export type AppToShell =
  | { type: "APP_READY"; payload: { appId: string; version: string } }
  | { type: "NAVIGATE"; payload: { path: string } }
  | { type: "AUTH_REQUEST"; payload: { reason: string } }
  | { type: "RESIZE"; payload: { height: number } }
  | { type: "ERROR"; payload: { appId: string; message: string; stack?: string } };
 
export interface AuthState {
  authenticated: boolean;
  user: { id: string; email: string; roles: string[] } | null;
  token: string | null;
  expiresAt: number | null;
}
 
export interface ThemeConfig {
  mode: "light" | "dark";
  primaryColor: string;
}
 
export interface MicroApp {
  id: string;
  name: string;
  origin: string;
  basePath: string;
  src: string;
}

MessageChannel Manager

// lib/channel-manager.ts
export class ChannelManager {
  private channels = new Map<string, MessagePort>();
  private pendingHandshakes = new Map<string, (port: MessagePort) => void>();
 
  /**
   * Initialize a MessageChannel with an iframe.
   * Returns a promise that resolves when the iframe acknowledges.
   */
  createChannel(
    appId: string,
    iframeWindow: Window,
    targetOrigin: string
  ): Promise<MessagePort> {
    return new Promise((resolve, reject) => {
      const channel = new MessageChannel();
      const port = channel.port1;
 
      const timeout = setTimeout(() => {
        this.pendingHandshakes.delete(appId);
        port.close();
        reject(new Error(`Channel handshake timeout for ${appId}`));
      }, 10000);
 
      // Wait for ACK on the port
      port.onmessage = (event: MessageEvent) => {
        if (event.data?.type === "CHANNEL_ACK") {
          clearTimeout(timeout);
          this.channels.set(appId, port);
          resolve(port);
        }
      };
      port.start();
 
      // Send port2 to the iframe
      iframeWindow.postMessage(
        { type: "INIT_CHANNEL", appId },
        targetOrigin,
        [channel.port2]
      );
    });
  }
 
  send(appId: string, message: unknown): void {
    const port = this.channels.get(appId);
    if (!port) throw new Error(`No channel for app: ${appId}`);
    port.postMessage(message);
  }
 
  onMessage(appId: string, handler: (data: unknown) => void): () => void {
    const port = this.channels.get(appId);
    if (!port) throw new Error(`No channel for app: ${appId}`);
    port.onmessage = (event) => handler(event.data);
    return () => { port.onmessage = null; };
  }
 
  close(appId: string): void {
    const port = this.channels.get(appId);
    if (port) {
      port.close();
      this.channels.delete(appId);
    }
  }
 
  closeAll(): void {
    for (const [id] of this.channels) {
      this.close(id);
    }
  }
}

Shell Application

import { useEffect, useRef, useState, useCallback } from "react";
import { ChannelManager } from "./lib/channel-manager";
import type { ShellToApp, AppToShell, AuthState, MicroApp } from "./types/shell";
 
const MICRO_APPS: MicroApp[] = [
  {
    id: "orders",
    name: "Orders",
    origin: "https://orders.example.com",
    basePath: "/orders",
    src: "https://orders.example.com/app",
  },
  {
    id: "inventory",
    name: "Inventory",
    origin: "https://inventory.example.com",
    basePath: "/inventory",
    src: "https://inventory.example.com/app",
  },
];
 
function Shell() {
  const channelManagerRef = useRef(new ChannelManager());
  const iframeRefs = useRef<Map<string, HTMLIFrameElement>>(new Map());
  const [readyApps, setReadyApps] = useState<Set<string>>(new Set());
  const [authState, setAuthState] = useState<AuthState>({
    authenticated: true,
    user: { id: "1", email: "user@example.com", roles: ["admin"] },
    token: "jwt-token-here",
    expiresAt: Date.now() + 3600000,
  });
 
  // Handle messages from micro-apps via their channels
  const setupAppChannel = useCallback(
    async (app: MicroApp, iframe: HTMLIFrameElement) => {
      const manager = channelManagerRef.current;
 
      try {
        const port = await manager.createChannel(
          app.id,
          iframe.contentWindow!,
          app.origin
        );
 
        // Listen for messages from this app
        manager.onMessage(app.id, (data) => {
          const msg = data as AppToShell;
          switch (msg.type) {
            case "APP_READY":
              setReadyApps((prev) => new Set(prev).add(msg.payload.appId));
              // Send initial state
              manager.send(app.id, {
                type: "AUTH_STATE",
                payload: authState,
              } satisfies ShellToApp);
              break;
 
            case "NAVIGATE":
              // Coordinate routing: update browser URL and notify other apps
              window.history.pushState(null, "", msg.payload.path);
              broadcastRoute(msg.payload.path, app.id);
              break;
 
            case "AUTH_REQUEST":
              console.log(`${app.id} requests auth: ${msg.payload.reason}`);
              // Trigger re-auth flow...
              break;
 
            case "RESIZE":
              const iframe = iframeRefs.current.get(app.id);
              if (iframe) iframe.style.height = `${msg.payload.height}px`;
              break;
 
            case "ERROR":
              console.error(`[${msg.payload.appId}]`, msg.payload.message);
              break;
          }
        });
      } catch (err) {
        console.error(`Failed to connect to ${app.id}:`, err);
      }
    },
    [authState]
  );
 
  // Broadcast route changes to all apps except the one that initiated it
  function broadcastRoute(path: string, sourceAppId: string) {
    const manager = channelManagerRef.current;
    for (const app of MICRO_APPS) {
      if (app.id === sourceAppId) continue;
      if (!readyApps.has(app.id)) continue;
      manager.send(app.id, {
        type: "ROUTE_CHANGE",
        payload: { path, params: {} },
      } satisfies ShellToApp);
    }
  }
 
  // Broadcast auth state changes to all apps
  useEffect(() => {
    const manager = channelManagerRef.current;
    for (const app of MICRO_APPS) {
      if (!readyApps.has(app.id)) continue;
      manager.send(app.id, {
        type: "AUTH_STATE",
        payload: authState,
      } satisfies ShellToApp);
    }
  }, [authState, readyApps]);
 
  // Cleanup on unmount
  useEffect(() => {
    return () => channelManagerRef.current.closeAll();
  }, []);
 
  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
      <header style={{ padding: 16, borderBottom: "1px solid #e2e8f0" }}>
        <h1>Micro-Frontend Shell</h1>
        <nav style={{ display: "flex", gap: 16 }}>
          {MICRO_APPS.map((app) => (
            <button
              key={app.id}
              onClick={() => broadcastRoute(app.basePath, "")}
            >
              {app.name} {readyApps.has(app.id) ? "✓" : "..."}
            </button>
          ))}
        </nav>
      </header>
      <main style={{ display: "flex", flex: 1, gap: 0 }}>
        {MICRO_APPS.map((app) => (
          <iframe
            key={app.id}
            ref={(el) => {
              if (el) iframeRefs.current.set(app.id, el);
            }}
            src={app.src}
            title={app.name}
            onLoad={(e) => setupAppChannel(app, e.currentTarget)}
            style={{ flex: 1, border: "none", minHeight: 400 }}
            sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
          />
        ))}
      </main>
    </div>
  );
}

Micro-App Bootstrap (inside each iframe)

// bootstrap.ts — runs inside each micro-frontend iframe
import type { ShellToApp, AppToShell } from "./types/shell";
 
let shellPort: MessagePort | null = null;
const messageHandlers = new Map<string, (msg: ShellToApp) => void>();
 
// Listen for the initial channel setup via postMessage
window.addEventListener("message", (event: MessageEvent) => {
  // Only accept INIT_CHANNEL from expected shell origin
  if (event.origin !== "https://shell.example.com") return;
  if (event.data?.type !== "INIT_CHANNEL") return;
  if (!event.ports[0]) return;
 
  shellPort = event.ports[0];
  shellPort.onmessage = (portEvent: MessageEvent<ShellToApp>) => {
    const msg = portEvent.data;
    const handler = messageHandlers.get(msg.type);
    if (handler) handler(msg);
  };
  shellPort.start();
 
  // Acknowledge the channel
  shellPort.postMessage({ type: "CHANNEL_ACK" });
 
  // Tell the shell we are ready
  sendToShell({
    type: "APP_READY",
    payload: { appId: "orders", version: "2.1.0" },
  });
});
 
export function sendToShell(message: AppToShell): void {
  if (!shellPort) {
    console.warn("Shell channel not established yet");
    return;
  }
  shellPort.postMessage(message);
}
 
export function onShellMessage(type: string, handler: (msg: ShellToApp) => void): () => void {
  messageHandlers.set(type, handler);
  return () => { messageHandlers.delete(type); };
}
 
// React hook for micro-app components
export function useShellAuth() {
  const [auth, setAuth] = useState<AuthState | null>(null);
 
  useEffect(() => {
    return onShellMessage("AUTH_STATE", (msg) => {
      if (msg.type === "AUTH_STATE") setAuth(msg.payload);
    });
  }, []);
 
  return auth;
}

Deep Dive

MessageChannel API

  • new MessageChannel() creates a pair of entangled MessagePort objects (port1 and port2). Messages sent on one arrive on the other.
  • Unlike window.postMessage, ports do not include origin information in their events. Origin validation happens once during the initial handshake (when port2 is transferred via postMessage). After that, the channel is trusted.
  • Ports must be explicitly started with port.start() unless you use the onmessage property (which auto-starts). If you use addEventListener("message", ...), you must call port.start() manually.
  • Ports can be transferred (not just between parent and iframe, but also between iframes, to workers, etc.). This enables multi-hop communication topologies.
  • Always call port.close() when done. Unclosed ports prevent garbage collection of the associated browsing contexts in some browsers.

Transferable Objects

  • By default, postMessage copies data using structured clone. For large payloads (ArrayBuffer, OffscreenCanvas, ImageBitmap), this copy is expensive.
  • The third argument to postMessage is an array of transferable objects. Transferring moves ownership to the receiver; the sender's reference becomes zero-length and unusable.
  • This is a zero-copy operation. For multi-megabyte buffers, transfer is orders of magnitude faster than clone.
// Transfer an ArrayBuffer (zero-copy, sender loses access)
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
iframe.contentWindow!.postMessage(
  { type: "BINARY_DATA", buffer },
  targetOrigin,
  [buffer] // Transfer list
);
// buffer.byteLength is now 0 on the sender side
 
// Transfer an OffscreenCanvas for off-main-thread rendering
const offscreen = canvasRef.current!.transferControlToOffscreen();
worker.postMessage({ type: "CANVAS", canvas: offscreen }, [offscreen]);

Transferable types:

TypeUse Case
ArrayBufferBinary data, file contents, WebAssembly memory
MessagePortEstablishing dedicated channels
ImageBitmapDecoded image data for canvas rendering
OffscreenCanvasOff-main-thread canvas rendering
ReadableStreamStreaming data to workers or iframes
WritableStreamStreaming data sinks
TransformStreamStream transformation pipes

Building a postMessage RPC Layer with Proxy

// lib/postmessage-rpc.ts
type RPCMethods = Record<string, (...args: any[]) => any>;
 
export function createRPCProxy<T extends RPCMethods>(
  port: MessagePort,
  timeoutMs = 5000
): T {
  const pending = new Map<string, {
    resolve: (value: unknown) => void;
    reject: (error: Error) => void;
  }>();
 
  port.onmessage = (event: MessageEvent) => {
    const { id, result, error } = event.data;
    const handler = pending.get(id);
    if (!handler) return;
    pending.delete(id);
    if (error) handler.reject(new Error(error));
    else handler.resolve(result);
  };
 
  return new Proxy({} as T, {
    get(_, method: string) {
      return (...args: unknown[]) => {
        return new Promise((resolve, reject) => {
          const id = crypto.randomUUID();
 
          const timer = setTimeout(() => {
            pending.delete(id);
            reject(new Error(`RPC timeout: ${method}`));
          }, timeoutMs);
 
          pending.set(id, {
            resolve: (val) => { clearTimeout(timer); resolve(val); },
            reject: (err) => { clearTimeout(timer); reject(err); },
          });
 
          port.postMessage({ type: "RPC_CALL", id, method, args });
        });
      };
    },
  });
}
 
// Server side: handle RPC calls
export function createRPCHandler(
  port: MessagePort,
  methods: RPCMethods
): void {
  port.onmessage = async (event: MessageEvent) => {
    const { type, id, method, args } = event.data;
    if (type !== "RPC_CALL") return;
 
    try {
      const fn = methods[method];
      if (!fn) throw new Error(`Unknown method: ${method}`);
      const result = await fn(...args);
      port.postMessage({ id, result });
    } catch (err) {
      port.postMessage({
        id,
        error: err instanceof Error ? err.message : "Unknown error",
      });
    }
  };
}
 
// Usage in parent:
interface WidgetAPI {
  getSelection(): Promise<SelectedPoint[]>;
  setZoom(level: number): Promise<void>;
  exportImage(format: "png" | "svg"): Promise<ArrayBuffer>;
}
 
const widget = createRPCProxy<WidgetAPI>(channelPort);
const points = await widget.getSelection();
await widget.setZoom(1.5);
 
// Usage in iframe:
createRPCHandler(shellPort, {
  getSelection: () => currentSelection,
  setZoom: (level: number) => { chart.setZoom(level); },
  exportImage: (format: string) => chart.export(format),
});

Timeout and Retry Patterns

async function sendWithRetry(
  target: Window,
  origin: string,
  message: unknown,
  options: { maxRetries?: number; timeoutMs?: number; backoffMs?: number } = {}
): Promise<unknown> {
  const { maxRetries = 3, timeoutMs = 3000, backoffMs = 1000 } = options;
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await sendRequest(target, origin, message, timeoutMs);
    } catch (err) {
      if (attempt === maxRetries) throw err;
      // Exponential backoff
      await new Promise((r) => setTimeout(r, backoffMs * Math.pow(2, attempt)));
    }
  }
  throw new Error("Unreachable");
}
 
// Health check pattern: verify iframe is responsive
async function isIframeAlive(
  iframeWindow: Window,
  origin: string
): Promise<boolean> {
  try {
    await sendRequest(iframeWindow, origin, { type: "PING" }, 2000);
    return true;
  } catch {
    return false;
  }
}

Sandboxed Iframes and CSP

  • The sandbox attribute restricts iframe capabilities. Without allow-scripts, the iframe cannot run JavaScript at all (and thus cannot use postMessage).
  • allow-same-origin lets the iframe retain its origin. Without it, the iframe is treated as a unique opaque origin, which means event.origin will be "null" (the string) and origin checks become meaningless.
  • Common sandbox configurations for micro-frontends:
Sandbox ValueEffect
allow-scriptsiframe can execute JavaScript
allow-same-originiframe keeps its real origin (needed for origin validation)
allow-formsiframe can submit forms
allow-popupsiframe can open new windows (e.g., OAuth flows)
allow-popups-to-escape-sandboxPopups opened by the iframe are not sandboxed
allow-modalsiframe can use alert(), confirm(), prompt()
allow-top-navigationiframe can navigate the top-level page (DANGEROUS for most cases)
  • Never use allow-top-navigation unless you specifically need the iframe to redirect the parent page. A malicious iframe could navigate the user to a phishing page.

Cross-Origin Auth Token Passing

// Secure pattern: send token only after verifying iframe identity
// via MessageChannel (origin validated during handshake)
 
// In the shell:
async function shareAuthWithApp(
  appId: string,
  port: MessagePort,
  tokenProvider: () => Promise<string>
) {
  // The channel was established with origin validation.
  // Port communication is now trusted.
  const token = await tokenProvider();
  port.postMessage({
    type: "AUTH_STATE",
    payload: {
      authenticated: true,
      token,
      expiresAt: Date.now() + 3600000,
    },
  });
}
 
// NEVER do this:
// iframe.contentWindow.postMessage({ token: "secret" }, "*");
// Using "*" means ANY origin can read the token!

Gotchas

  • MessagePort.start() is required when using addEventListener. If you set port.onmessage directly, start is called automatically. If you use port.addEventListener("message", handler), you must call port.start() explicitly or you will silently receive no messages.
  • Transferred objects become neutered. After transferring an ArrayBuffer, the sender's reference has byteLength === 0. Any attempt to read or write it throws. Do not reference the buffer after transfer.
  • Sandbox allow-same-origin is not about CORS. It controls whether the iframe reports its real origin. Without it, cookies, localStorage, and origin-based security all break. But adding allow-same-origin alongside allow-scripts means the iframe can remove its own sandbox (by removing the sandbox attribute from itself). Only do this with trusted content.
  • MessageChannel ports are garbage-collected when both ends lose their references. This can happen unexpectedly if you store a port in a variable that goes out of scope. Store ports in a Map or Set that persists for the lifetime of the communication.
  • RPC proxy methods are all async. Even if the handler returns a synchronous value, the proxy wraps it in a promise. Callers must always await the result.
  • Iframe load event fires before app JavaScript runs. Setting up the channel in the iframe's onLoad handler on the parent side sends INIT_CHANNEL before the iframe's listener is registered. Use a handshake: the iframe sends READY first, then the parent initiates the channel.
  • CSP frame-ancestors on the iframe's server controls who can embed it. If the iframe's CSP does not include your shell's origin, the browser will refuse to load the iframe entirely. This is a server-side configuration, not something you can fix in JavaScript.

Alternatives

ApproachWhen to Use
Module Federation (webpack or Vite)Micro-frontends that can share the same JavaScript runtime (no iframe isolation)
single-spaMicro-frontend orchestration without iframes; apps share a single DOM
Web ComponentsEncapsulated UI components without full iframe isolation
ComlinkLibrary that wraps the MessageChannel/Proxy RPC pattern shown above, with a cleaner API
PartytownOffloads third-party scripts to a web worker using postMessage under the hood

FAQs

How does MessageChannel differ from window.postMessage for iframe communication?
  • MessageChannel creates a dedicated pair of entangled MessagePort objects for point-to-point communication.
  • After the initial handshake (which uses postMessage with origin validation), the channel is trusted and no further origin checks are needed.
  • Ports must be explicitly started and closed.
Why must you call port.start() when using addEventListener on a MessagePort?
  • Setting port.onmessage directly auto-starts the port.
  • Using port.addEventListener("message", handler) does not auto-start it.
  • Without calling port.start(), you will silently receive no messages.
What is a transferable object and why use it instead of structured clone?
  • Transferring moves ownership of the data to the receiver (zero-copy), while cloning duplicates it.
  • For large ArrayBuffer payloads, transfer is orders of magnitude faster.
  • After transfer, the sender's reference becomes neutered (byteLength === 0).
Gotcha: What happens if you read an ArrayBuffer after transferring it?
  • The sender's reference has byteLength === 0 and any read/write attempt throws.
  • Do not reference the buffer after including it in the transfer list.
How does the RPC Proxy pattern work over MessageChannel?
const widget = createRPCProxy<WidgetAPI>(port);
const points = await widget.getSelection();
// Proxy intercepts the method call, sends an RPC
// message over the port, and returns a promise
// that resolves when the response arrives.
How do you type the RPC proxy methods in TypeScript?
interface WidgetAPI {
  getSelection(): Promise<SelectedPoint[]>;
  setZoom(level: number): Promise<void>;
  exportImage(format: "png" | "svg"): Promise<ArrayBuffer>;
}
const widget = createRPCProxy<WidgetAPI>(port);
// All methods are typed and return Promises
Why should you never use allow-top-navigation in a sandbox attribute?
  • It lets the iframe navigate the parent page, which is a phishing risk.
  • A malicious iframe could redirect the user to a lookalike page to steal credentials.
  • Use allow-top-navigation-by-user-activation if navigation is truly needed.
What sandbox values are typically needed for a micro-frontend iframe?
  • allow-scripts (JavaScript execution), allow-same-origin (retains real origin for validation), allow-forms (form submission), and optionally allow-popups (OAuth flows).
  • Without allow-scripts, postMessage communication from the iframe is impossible.
  • Without allow-same-origin, event.origin becomes the string "null".
Gotcha: Why does the iframe load event fire before the app JavaScript runs?
  • The load event fires when the iframe document finishes loading, but before framework JavaScript initializes.
  • Sending INIT_CHANNEL in the parent's onLoad handler may arrive before the iframe's listener is registered.
  • Use a handshake: the iframe sends READY first, then the parent initiates the channel.
How do you implement a retry pattern for postMessage requests?
  • Wrap sendRequest in a loop with a max retry count and exponential backoff.
  • On timeout, wait backoffMs * 2^attempt before retrying.
  • After exhausting retries, throw the error to the caller.
What is the CSP frame-ancestors directive and why does it matter?
  • frame-ancestors controls which origins can embed your page in an iframe; it is set as an HTTP response header on the iframe's server.
  • If the embedding origin is not in the list, the browser refuses to load the iframe entirely.
  • It is defense in depth: even if JavaScript origin checks have a bug, frame-ancestors prevents unauthorized embedding.
How do you securely pass auth tokens from a shell to a micro-frontend?
  • Establish a MessageChannel with origin validation during the handshake.
  • Send the token over the trusted port, never via postMessage with "*" as target origin.
  • The port communication is trusted after the initial handshake.