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 entangledMessagePortobjects (port1andport2). 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 viapostMessage). After that, the channel is trusted. - Ports must be explicitly started with
port.start()unless you use theonmessageproperty (which auto-starts). If you useaddEventListener("message", ...), you must callport.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,
postMessagecopies data using structured clone. For large payloads (ArrayBuffer,OffscreenCanvas,ImageBitmap), this copy is expensive. - The third argument to
postMessageis 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:
| Type | Use Case |
|---|---|
ArrayBuffer | Binary data, file contents, WebAssembly memory |
MessagePort | Establishing dedicated channels |
ImageBitmap | Decoded image data for canvas rendering |
OffscreenCanvas | Off-main-thread canvas rendering |
ReadableStream | Streaming data to workers or iframes |
WritableStream | Streaming data sinks |
TransformStream | Stream 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
sandboxattribute restricts iframe capabilities. Withoutallow-scripts, the iframe cannot run JavaScript at all (and thus cannot use postMessage). allow-same-originlets the iframe retain its origin. Without it, the iframe is treated as a unique opaque origin, which meansevent.originwill be"null"(the string) and origin checks become meaningless.- Common sandbox configurations for micro-frontends:
| Sandbox Value | Effect |
|---|---|
allow-scripts | iframe can execute JavaScript |
allow-same-origin | iframe keeps its real origin (needed for origin validation) |
allow-forms | iframe can submit forms |
allow-popups | iframe can open new windows (e.g., OAuth flows) |
allow-popups-to-escape-sandbox | Popups opened by the iframe are not sandboxed |
allow-modals | iframe can use alert(), confirm(), prompt() |
allow-top-navigation | iframe can navigate the top-level page (DANGEROUS for most cases) |
- Never use
allow-top-navigationunless 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 setport.onmessagedirectly, start is called automatically. If you useport.addEventListener("message", handler), you must callport.start()explicitly or you will silently receive no messages. - Transferred objects become neutered. After transferring an
ArrayBuffer, the sender's reference hasbyteLength === 0. Any attempt to read or write it throws. Do not reference the buffer after transfer. - Sandbox
allow-same-originis not about CORS. It controls whether the iframe reports its real origin. Without it, cookies, localStorage, and origin-based security all break. But addingallow-same-originalongsideallow-scriptsmeans 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
MaporSetthat 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
awaitthe result. - Iframe
loadevent fires before app JavaScript runs. Setting up the channel in the iframe'sonLoadhandler on the parent side sendsINIT_CHANNELbefore the iframe's listener is registered. Use a handshake: the iframe sendsREADYfirst, then the parent initiates the channel. - CSP
frame-ancestorson 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
| Approach | When to Use |
|---|---|
| Module Federation (webpack or Vite) | Micro-frontends that can share the same JavaScript runtime (no iframe isolation) |
| single-spa | Micro-frontend orchestration without iframes; apps share a single DOM |
| Web Components | Encapsulated UI components without full iframe isolation |
| Comlink | Library that wraps the MessageChannel/Proxy RPC pattern shown above, with a cleaner API |
| Partytown | Offloads third-party scripts to a web worker using postMessage under the hood |
FAQs
How does MessageChannel differ from window.postMessage for iframe communication?
MessageChannelcreates a dedicated pair of entangledMessagePortobjects for point-to-point communication.- After the initial handshake (which uses
postMessagewith 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.onmessagedirectly 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
ArrayBufferpayloads, 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 === 0and 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 PromisesWhy 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-activationif 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 optionallyallow-popups(OAuth flows).- Without
allow-scripts, postMessage communication from the iframe is impossible. - Without
allow-same-origin,event.originbecomes the string"null".
Gotcha: Why does the iframe load event fire before the app JavaScript runs?
- The
loadevent fires when the iframe document finishes loading, but before framework JavaScript initializes. - Sending
INIT_CHANNELin the parent'sonLoadhandler may arrive before the iframe's listener is registered. - Use a handshake: the iframe sends
READYfirst, then the parent initiates the channel.
How do you implement a retry pattern for postMessage requests?
- Wrap
sendRequestin a loop with a max retry count and exponential backoff. - On timeout, wait
backoffMs * 2^attemptbefore retrying. - After exhausting retries, throw the error to the caller.
What is the CSP frame-ancestors directive and why does it matter?
frame-ancestorscontrols 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-ancestorsprevents unauthorized embedding.
How do you securely pass auth tokens from a shell to a micro-frontend?
- Establish a
MessageChannelwith origin validation during the handshake. - Send the token over the trusted port, never via
postMessagewith"*"as target origin. - The port communication is trusted after the initial handshake.
Related
- postMessage Fundamentals — MessageEvent anatomy, origin validation, typed protocols
- Intermediate postMessage Patterns — request/response, message bus hook, multi-iframe routing
- postMessage Security — origin validation pitfalls, CSP headers, sandbox permissions, secure widget patterns