Intermediate postMessage Patterns — Request/response, message bus hooks, and multi-iframe routing
Recipe
// Request/response pattern: correlate replies with unique IDs
function sendRequest<T>(
target: Window,
origin: string,
message: { type: string; payload?: unknown },
timeoutMs = 5000
): Promise<T> {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
function handleReply(event: MessageEvent) {
if (event.origin !== origin) return;
if (event.data?.correlationId !== id) return;
window.removeEventListener("message", handleReply);
clearTimeout(timer);
resolve(event.data.payload as T);
}
const timer = setTimeout(() => {
window.removeEventListener("message", handleReply);
reject(new Error(`postMessage timeout after ${timeoutMs}ms`));
}, timeoutMs);
window.addEventListener("message", handleReply);
target.postMessage({ ...message, correlationId: id }, origin);
});
}When to reach for this: When simple fire-and-forget messages are not enough. You need correlated request/response pairs, a reusable hook for postMessage communication, bidirectional data flow, or routing messages to specific iframes in a multi-iframe layout.
Working Example
A dashboard embeds a chart widget in an iframe. The parent sends data updates and receives click events back from the chart.
Shared Message Types
// types/messages.ts — shared between parent and iframe
export type ParentToChart =
| { type: "DATA_UPDATE"; correlationId?: string; payload: ChartData }
| { type: "SET_OPTIONS"; correlationId?: string; payload: ChartOptions }
| { type: "REQUEST_SELECTION"; correlationId: string };
export type ChartToParent =
| { type: "CHART_CLICK"; payload: { seriesIndex: number; dataIndex: number; value: number } }
| { type: "CHART_READY" }
| { type: "SELECTION_RESPONSE"; correlationId: string; payload: SelectedPoint[] }
| { type: "ERROR"; payload: { message: string } };
export interface ChartData {
labels: string[];
series: { name: string; values: number[] }[];
}
export interface ChartOptions {
animate: boolean;
showLegend: boolean;
colorScheme: "default" | "warm" | "cool";
}
export interface SelectedPoint {
seriesName: string;
label: string;
value: number;
}usePostMessage Hook
// hooks/usePostMessage.ts
import { useEffect, useCallback, useRef } from "react";
type MessageHandler<T = unknown> = (
data: T,
event: MessageEvent
) => void;
interface UsePostMessageOptions {
/** Allowed origins — messages from other origins are silently dropped */
allowedOrigins: string[];
/** Optional filter: only handle messages where data.type matches */
messageTypes?: string[];
}
export function usePostMessage<TIncoming = unknown>(
handler: MessageHandler<TIncoming>,
options: UsePostMessageOptions
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
const originsRef = useRef(options.allowedOrigins);
originsRef.current = options.allowedOrigins;
const typesRef = useRef(options.messageTypes);
typesRef.current = options.messageTypes;
useEffect(() => {
function onMessage(event: MessageEvent) {
// Origin allowlist check
if (!originsRef.current.includes(event.origin)) return;
// Optional type filter
const types = typesRef.current;
if (types && types.length > 0) {
const msgType = event.data?.type;
if (!types.includes(msgType)) return;
}
handlerRef.current(event.data as TIncoming, event);
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []); // Stable: refs handle updates without re-subscribing
// Send helper
const send = useCallback(
(target: Window, message: unknown, origin: string) => {
target.postMessage(message, origin);
},
[]
);
return { send };
}Parent Dashboard
import { useRef, useState, useCallback } from "react";
import { usePostMessage } from "./hooks/usePostMessage";
import type { ParentToChart, ChartToParent, ChartData } from "./types/messages";
const CHART_ORIGIN = "https://charts.example.com";
function Dashboard() {
const chartRef = useRef<HTMLIFrameElement>(null);
const [chartReady, setChartReady] = useState(false);
const [lastClick, setLastClick] = useState<string | null>(null);
const { send } = usePostMessage<ChartToParent>(
useCallback((data, event) => {
switch (data.type) {
case "CHART_READY":
setChartReady(true);
break;
case "CHART_CLICK":
setLastClick(
`Series ${data.payload.seriesIndex}, ` +
`point ${data.payload.dataIndex}: ${data.payload.value}`
);
break;
case "SELECTION_RESPONSE":
console.log("Selected points:", data.payload);
break;
case "ERROR":
console.error("Chart error:", data.payload.message);
break;
}
}, []),
{ allowedOrigins: [CHART_ORIGIN] }
);
function sendData(data: ChartData) {
if (!chartRef.current?.contentWindow) return;
const msg: ParentToChart = { type: "DATA_UPDATE", payload: data };
send(chartRef.current.contentWindow, msg, CHART_ORIGIN);
}
// Request/response: ask the chart for current selection
async function getSelection() {
if (!chartRef.current?.contentWindow) return;
try {
const result = await sendRequest<{ payload: unknown }>(
chartRef.current.contentWindow,
CHART_ORIGIN,
{ type: "REQUEST_SELECTION" },
3000
);
console.log("Selection:", result);
} catch (err) {
console.error("Selection request timed out");
}
}
return (
<div>
<h1>Dashboard</h1>
<div style={{ display: "flex", gap: 8 }}>
<button
disabled={!chartReady}
onClick={() =>
sendData({
labels: ["Jan", "Feb", "Mar"],
series: [{ name: "Revenue", values: [100, 150, 130] }],
})
}
>
Send Data
</button>
<button disabled={!chartReady} onClick={getSelection}>
Get Selection
</button>
</div>
{lastClick && <p>Last click: {lastClick}</p>}
<iframe
ref={chartRef}
src={`${CHART_ORIGIN}/chart-widget`}
title="Chart Widget"
style={{ width: "100%", height: 400, border: "1px solid #e2e8f0" }}
/>
</div>
);
}Chart Widget (in iframe)
import { useEffect, useCallback, useState } from "react";
import { usePostMessage } from "./hooks/usePostMessage";
import type { ParentToChart, ChartToParent, ChartData, SelectedPoint } from "./types/messages";
const PARENT_ORIGIN = "https://dashboard.example.com";
function ChartWidget() {
const [data, setData] = useState<ChartData | null>(null);
const [selected, setSelected] = useState<SelectedPoint[]>([]);
const { send } = usePostMessage<ParentToChart>(
useCallback((msg, event) => {
const reply = (response: ChartToParent) => {
(event.source as Window).postMessage(response, event.origin);
};
switch (msg.type) {
case "DATA_UPDATE":
setData(msg.payload);
break;
case "SET_OPTIONS":
// apply chart options...
break;
case "REQUEST_SELECTION":
// Respond with current selection, preserving correlationId
reply({
type: "SELECTION_RESPONSE",
correlationId: msg.correlationId,
payload: selected,
});
break;
}
}, [selected]),
{ allowedOrigins: [PARENT_ORIGIN] }
);
// Notify parent we are ready
useEffect(() => {
if (!window.parent || window.parent === window) return;
const msg: ChartToParent = { type: "CHART_READY" };
window.parent.postMessage(msg, PARENT_ORIGIN);
}, []);
function handleBarClick(seriesIndex: number, dataIndex: number, value: number) {
const clickMsg: ChartToParent = {
type: "CHART_CLICK",
payload: { seriesIndex, dataIndex, value },
};
window.parent.postMessage(clickMsg, PARENT_ORIGIN);
}
if (!data) return <p>Waiting for data...</p>;
return (
<div style={{ padding: 16 }}>
<h3>Chart Widget</h3>
{data.series.map((series, si) => (
<div key={series.name}>
<h4>{series.name}</h4>
<div style={{ display: "flex", gap: 4, alignItems: "flex-end", height: 200 }}>
{series.values.map((val, di) => (
<div
key={di}
onClick={() => handleBarClick(si, di, val)}
style={{
width: 40,
height: `${(val / Math.max(...series.values)) * 100}%`,
background: "#3b82f6",
cursor: "pointer",
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
color: "white",
fontSize: 12,
paddingBottom: 4,
}}
>
{val}
</div>
))}
</div>
<div style={{ display: "flex", gap: 4 }}>
{data.labels.map((label) => (
<div key={label} style={{ width: 40, textAlign: "center", fontSize: 11 }}>
{label}
</div>
))}
</div>
</div>
))}
</div>
);
}Deep Dive
Request/Response with Correlation IDs
- Fire-and-forget postMessage is insufficient when you need a return value. The solution is to attach a unique
correlationIdto each request and have the responder echo it back. - Use
crypto.randomUUID()for IDs. It is available in all modern browsers and is cryptographically random. - Always implement a timeout. The iframe may never respond (crashed, wrong origin, blocked by CSP). Without a timeout, your promise hangs forever.
- Clean up the event listener in both the resolve and reject paths to prevent memory leaks.
How the usePostMessage Hook Works
- The hook stores the handler in a ref so the effect closure never goes stale. This means you can reference current state in your handler without re-subscribing the listener.
- Origin checking happens inside the effect, using a ref for the allowlist, so updating the allowlist does not tear down and re-add the listener.
- The
sendhelper is a stable callback (wrapped inuseCallbackwith empty deps) so it can be passed as a prop without causing re-renders. - The optional
messageTypesfilter lets you scope a component's handler to only relevant message types, which keeps handlers focused.
Multi-Iframe Routing
- When your page embeds multiple iframes, every
messagelistener fires for every postMessage from every iframe. You need to route messages to the right handler. - Strategy 1: Check
event.sourceagainst iframe refs. This is the most reliable approach. - Strategy 2: Add a
sourceorchannelfield to your message protocol so handlers can filter by logical source. - Strategy 3: Use MessageChannel (covered in the advanced guide) to create a dedicated port per iframe.
function useIframeMessage(
iframeRef: React.RefObject<HTMLIFrameElement | null>,
origin: string,
handler: (data: unknown) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin !== origin) return;
// Only handle messages from THIS specific iframe
if (event.source !== iframeRef.current?.contentWindow) return;
handlerRef.current(event.data);
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [origin, iframeRef]);
}
// Usage: each iframe gets its own scoped handler
function MultiIframePage() {
const chartRef = useRef<HTMLIFrameElement>(null);
const formRef = useRef<HTMLIFrameElement>(null);
useIframeMessage(chartRef, "https://charts.example.com", (data) => {
console.log("From chart:", data);
});
useIframeMessage(formRef, "https://forms.example.com", (data) => {
console.log("From form:", data);
});
return (
<>
<iframe ref={chartRef} src="https://charts.example.com/widget" title="Chart" />
<iframe ref={formRef} src="https://forms.example.com/widget" title="Form" />
</>
);
}Serialization: What Can and Cannot Be Sent
The structured clone algorithm supports more types than JSON but still has limits:
| Supported | Not Supported |
|---|---|
| Primitives (string, number, boolean, null, undefined) | Functions |
| Plain objects and arrays | DOM nodes (Element, Document, etc.) |
Date | Symbol |
Map, Set | WeakMap, WeakSet |
RegExp | Class instances (prototype is lost) |
ArrayBuffer, typed arrays (Uint8Array, etc.) | Error objects (in some browsers) |
Blob, File, FileList | Getters, setters, property descriptors |
ImageBitmap, ImageData | Proxy objects |
| Nested objects with circular references | Closures |
Key details:
- Class instances lose their prototype. A
class User { getName() {} }instance becomes a plain object on the other side. Only own enumerable properties survive. - Circular references are handled. Unlike
JSON.stringify, structured clone can serialize objects with cycles. ArrayBufferis copied by default. Use thetransferparameter to transfer ownership (zero-copy) instead. See the advanced guide.Errorobjects have inconsistent cloning support across browsers. Send{ message: error.message, stack: error.stack }instead.
Gotchas
- No built-in request/response. postMessage is fire-and-forget. You must build correlation yourself. Forgetting the timeout in your request/response wrapper will cause promise leaks.
- Stale closures in handlers. If your message handler references React state directly inside a
useEffect, you will read stale values. Use a ref for the handler function (as shown in the hook) or include state in the dependency array (which re-subscribes the listener on every change). - Message ordering is guaranteed for messages sent from the same source to the same target. But if you have multiple iframes sending to the parent, the interleaving order between iframes is not guaranteed.
contentWindowis null until the iframe element is in the DOM and has started loading. Always null-check before callingpostMessage.- Iframe navigation resets the connection. If the iframe navigates to a new page,
contentWindowstill exists but now points to the new document. Any state in the old document is gone. You need the new page to send a freshREADYmessage. - Serialization failures are silent. If you accidentally include a function in your message payload,
postMessagethrows aDataCloneError. This can be surprising because the error happens on the sender side, not the receiver. - Browser extensions inject iframes. Your global
messagelistener may receive messages from extension iframes you did not create. Always validate origin and message shape.
Alternatives
| Approach | When to Use |
|---|---|
| MessageChannel | Dedicated bidirectional port for exactly two endpoints (no broadcast, no origin checking needed after setup) |
| BroadcastChannel | Same-origin fan-out to all tabs and windows (no cross-origin) |
| Comlink (library) | High-level RPC over postMessage for workers and iframes; hides the protocol entirely |
iframe src URL params | One-time initial config (no ongoing communication) |
| Shared state via server | When iframes are on different domains and need persistent shared state |
FAQs
Why do you need correlation IDs for postMessage request/response patterns?
postMessageis fire-and-forget with no built-in return value.- A unique
correlationId(viacrypto.randomUUID()) lets you match a response to the original request. - Without it, you cannot tell which response belongs to which request when multiple are in flight.
What happens if you forget to add a timeout to your request/response wrapper?
- The promise hangs forever if the iframe never responds (crashed, wrong origin, blocked by CSP).
- Always implement a timeout that cleans up the event listener and rejects the promise.
How does the usePostMessage hook avoid stale closure issues?
- The handler is stored in a
useRef, updated on every render viahandlerRef.current = handler. - The
useEffectclosure reads from the ref, so it always calls the latest handler without re-subscribing the listener.
How do you type the usePostMessage hook generically in TypeScript?
export function usePostMessage<TIncoming = unknown>(
handler: (data: TIncoming, event: MessageEvent) => void,
options: { allowedOrigins: string[] }
) {
// TIncoming narrows event.data inside the handler
}What are three strategies for routing messages when you have multiple iframes?
- Check
event.sourceagainst iframe refs to identify which iframe sent the message. - Add a
sourceorchannelfield to your message protocol for logical routing. - Use
MessageChannelto create a dedicated port per iframe (covered in the advanced guide).
Gotcha: What happens to class instances when sent through postMessage?
- Class instances lose their prototype during structured cloning. Only own enumerable properties survive.
- A
class User { getName() {} }instance becomes a plain object on the receiving side. - Send plain objects or reconstruct instances on the receiver.
Are messages ordered when sent from multiple iframes to a single parent?
- Messages from the same source to the same target are guaranteed to arrive in order.
- Messages from different iframes to the same parent can interleave in any order.
What is a DataCloneError and when does it occur?
- It is thrown on the sender side when you include a non-cloneable value (like a function) in the message payload.
- It can be surprising because the error happens at
postMessage(), not on the receiver. - Use
structuredClone()locally to test if your payload is cloneable.
Gotcha: Why does contentWindow become unreliable after an iframe navigates?
- If the iframe navigates to a new page,
contentWindowstill exists but points to the new document. - All state in the old document is gone. You need the new page to send a fresh
READYmessage.
How do you make the send helper from usePostMessage stable across re-renders?
const send = useCallback(
(target: Window, message: unknown, origin: string) => {
target.postMessage(message, origin);
},
[] // empty deps = stable reference
);What TypeScript type should you use for the messageTypes filter option?
- Use
string[]for the allowed message type names. - The hook filters
event.data?.typeagainst this array before invoking the handler. - This scopes each component's handler to only relevant messages.
Why might your message listener fire for messages you did not send?
- Browser extensions inject iframes and content scripts that can call
window.postMessage. - Your global
messagelistener receives messages from extension iframes. - Always validate both origin and message shape (the
typefield).
Related
- postMessage Fundamentals — MessageEvent anatomy, origin validation basics, typed protocols
- Advanced postMessage Patterns — MessageChannel, transferables, micro-frontend architecture, RPC layer
- postMessage Security — origin validation pitfalls, CSP, sandboxing