Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
A dashboard embeds a chart widget in an iframe. The parent sends data updates and receives click events back from the chart.
// 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;
}// 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 };
}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>
);
}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>
);
}correlationId to each request and have the responder echo it back.crypto.randomUUID() for IDs. It is available in all modern browsers and is cryptographically random.send helper is a stable callback (wrapped in useCallback with empty deps) so it can be passed as a prop without causing re-renders.messageTypes filter lets you scope a component's handler to only relevant message types, which keeps handlers focused.message listener fires for every postMessage from every iframe. You need to route messages to the right handler.event.source against iframe refs. This is the most reliable approach.source or channel field to your message protocol so handlers can filter by logical source.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" />
</>
);
}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 User { getName() {} } instance becomes a plain object on the other side. Only own enumerable properties survive.JSON.stringify, structured clone can serialize objects with cycles.ArrayBuffer is copied by default. Use the transfer parameter to transfer ownership (zero-copy) instead. See the advanced guide.Error objects have inconsistent cloning support across browsers. Send { message: error.message, stack: error.stack } instead.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).contentWindow is null until the iframe element is in the DOM and has started loading. Always null-check before calling postMessage.contentWindow still exists but now points to the new document. Any state in the old document is gone. You need the new page to send a fresh READY message.postMessage throws a DataCloneError. This can be surprising because the error happens on the sender side, not the receiver.message listener may receive messages from extension iframes you did not create. Always validate origin and message shape.
| 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 |
postMessage is fire-and-forget with no built-in return value.correlationId (via crypto.randomUUID()) lets you match a response to the original request.useRef, updated on every render via handlerRef.current = handler.useEffect closure reads from the ref, so it always calls the latest handler without re-subscribing the listener.export function usePostMessage<TIncoming = unknown>(
handler: (data: TIncoming, event: MessageEvent) => void,
options: { allowedOrigins: string[] }
) {
// TIncoming narrows event.data inside the handler
}event.source against iframe refs to identify which iframe sent the message.source or channel field to your message protocol for logical routing.MessageChannel to create a dedicated port per iframe (covered in the advanced guide).class User { getName() {} } instance becomes a plain object on the receiving side.postMessage(), not on the receiver.structuredClone() locally to test if your payload is cloneable.contentWindow still exists but points to the new document.READY message.const send = useCallback(
(target: Window, message: unknown, origin: string) => {
target.postMessage(message, origin);
},
[] // empty deps = stable reference
);string[] for the allowed message type names.event.data?.type against this array before invoking the handler.window.postMessage.message listener receives messages from extension iframes.type field).How to Send Data From iframe To Parent Page - JavaScript postMessage Tutorial
Window postMessage() protocol using React - #1 JavaScript Tutorials
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥