React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

postMessageiframecross-originMessageEventbrowser-apis

postMessage Fundamentals — Secure cross-origin communication between windows and iframes

Recipe

// Parent sends a message to an iframe
const iframeRef = useRef<HTMLIFrameElement>(null);
 
function sendToIframe(data: unknown) {
  iframeRef.current?.contentWindow?.postMessage(
    data,
    "https://widget.example.com" // ALWAYS specify the target origin
  );
}
 
// Iframe (or any window) listens for messages
useEffect(() => {
  function handleMessage(event: MessageEvent) {
    // CRITICAL: always validate the origin
    if (event.origin !== "https://parent.example.com") return;
    console.log("Received:", event.data);
  }
  window.addEventListener("message", handleMessage);
  return () => window.removeEventListener("message", handleMessage);
}, []);

When to reach for this: When you need communication between a parent page and an embedded iframe, between windows opened with window.open, or between any two browsing contexts that cannot share JavaScript scope directly. Common use cases include micro-frontends, embedded third-party widgets, payment forms, OAuth popups, and cross-origin auth flows.

Working Example

A parent page sends a theme object to an embedded iframe. The iframe applies the theme and sends back an acknowledgement.

Parent App

import { useEffect, useRef, useState } from "react";
 
// -- Typed message protocol using discriminated unions --
 
type ParentMessage =
  | { type: "THEME_UPDATE"; payload: Theme }
  | { type: "PING" };
 
type IframeMessage =
  | { type: "THEME_ACK"; payload: { appliedAt: number } }
  | { type: "PONG" };
 
interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
  fontSize: number;
}
 
const IFRAME_ORIGIN = "https://widget.example.com";
 
function ParentApp() {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const [acked, setAcked] = useState(false);
 
  // Listen for responses from the iframe
  useEffect(() => {
    function handleMessage(event: MessageEvent<IframeMessage>) {
      if (event.origin !== IFRAME_ORIGIN) return;
 
      switch (event.data.type) {
        case "THEME_ACK":
          console.log("Theme applied at:", event.data.payload.appliedAt);
          setAcked(true);
          break;
        case "PONG":
          console.log("Iframe is alive");
          break;
      }
    }
 
    window.addEventListener("message", handleMessage);
    return () => window.removeEventListener("message", handleMessage);
  }, []);
 
  function sendTheme(mode: "light" | "dark") {
    const message: ParentMessage = {
      type: "THEME_UPDATE",
      payload: { mode, primaryColor: "#3b82f6", fontSize: 16 },
    };
    iframeRef.current?.contentWindow?.postMessage(message, IFRAME_ORIGIN);
    setAcked(false);
  }
 
  return (
    <div>
      <h1>Parent App</h1>
      <button onClick={() => sendTheme("light")}>Light Theme</button>
      <button onClick={() => sendTheme("dark")}>Dark Theme</button>
      {acked && <p>Theme acknowledged by widget</p>}
      <iframe
        ref={iframeRef}
        src={`${IFRAME_ORIGIN}/widget`}
        title="Embedded Widget"
        style={{ width: 600, height: 400, border: "1px solid #ccc" }}
      />
    </div>
  );
}

Iframe Widget

import { useEffect, useState } from "react";
 
interface Theme {
  mode: "light" | "dark";
  primaryColor: string;
  fontSize: number;
}
 
type ParentMessage =
  | { type: "THEME_UPDATE"; payload: Theme }
  | { type: "PING" };
 
type IframeMessage =
  | { type: "THEME_ACK"; payload: { appliedAt: number } }
  | { type: "PONG" };
 
const PARENT_ORIGIN = "https://parent.example.com";
 
function Widget() {
  const [theme, setTheme] = useState<Theme>({
    mode: "light",
    primaryColor: "#3b82f6",
    fontSize: 16,
  });
 
  useEffect(() => {
    function handleMessage(event: MessageEvent<ParentMessage>) {
      // CRITICAL: validate origin before processing
      if (event.origin !== PARENT_ORIGIN) return;
 
      switch (event.data.type) {
        case "THEME_UPDATE":
          setTheme(event.data.payload);
          // Send acknowledgement back to parent
          const ack: IframeMessage = {
            type: "THEME_ACK",
            payload: { appliedAt: Date.now() },
          };
          // event.source is the window that sent the message
          (event.source as Window).postMessage(ack, event.origin);
          break;
 
        case "PING":
          (event.source as Window).postMessage(
            { type: "PONG" } satisfies IframeMessage,
            event.origin
          );
          break;
      }
    }
 
    window.addEventListener("message", handleMessage);
    return () => window.removeEventListener("message", handleMessage);
  }, []);
 
  return (
    <div
      style={{
        backgroundColor: theme.mode === "dark" ? "#1e293b" : "#ffffff",
        color: theme.mode === "dark" ? "#f1f5f9" : "#0f172a",
        fontSize: theme.fontSize,
        padding: 24,
        minHeight: "100vh",
      }}
    >
      <h2 style={{ color: theme.primaryColor }}>Widget Content</h2>
      <p>Current theme: {theme.mode}</p>
    </div>
  );
}

Deep Dive

What is postMessage?

  • window.postMessage() is the browser-standard API for safe cross-origin communication between browsing contexts (windows, iframes, popups, workers).
  • Without postMessage, the same-origin policy blocks JavaScript in one origin from accessing the DOM or variables of another origin. postMessage provides a controlled channel to send serializable data across that boundary.
  • The method signature is targetWindow.postMessage(message, targetOrigin, transfer?).

MessageEvent Anatomy

Every message handler receives a MessageEvent with these key properties:

PropertyTypeDescription
dataanyThe message payload (structured-clone of what was sent)
originstringThe origin of the sending window (e.g., https://example.com)
sourceWindow or MessagePort or ServiceWorker or nullReference to the sender window; use to reply
portsReadonlyArray<MessagePort>MessageChannel ports transferred with the message
lastEventIdstringEmpty string for postMessage (used by SSE)

How It Works

  • The sender calls postMessage(data, targetOrigin) on the target window object (not its own).
  • The browser checks whether the target window's current origin matches targetOrigin. If it does not match, the message is silently dropped.
  • The message payload is serialized using the structured clone algorithm (not JSON). This supports Date, Map, Set, ArrayBuffer, Blob, File, RegExp, typed arrays, and nested objects. It does not support functions, DOM nodes, Symbol, or WeakMap/WeakSet.
  • The message is dispatched asynchronously (it goes through the event loop). It is never synchronous.
  • The receiving window's "message" event listener fires with a MessageEvent containing the cloned data, the sender's origin, and a reference to the sender's window.

Origin Validation is Non-Negotiable

  • Always check event.origin in every message handler. Without this check, any page that can iframe your page (or that your page iframes) can send arbitrary messages to your handler.
  • Always specify targetOrigin when calling postMessage. Using "*" means any origin can receive the message. This is dangerous when the message contains sensitive data (tokens, user info, etc.). See the security doc for full details.
  • Use an allowlist of known origins, not a regex pattern match (regexes are easy to get wrong and can be bypassed).

React Integration Pattern

// Clean useEffect pattern for postMessage listeners
useEffect(() => {
  const ALLOWED_ORIGINS = new Set([
    "https://widget-a.example.com",
    "https://widget-b.example.com",
  ]);
 
  function handleMessage(event: MessageEvent) {
    if (!ALLOWED_ORIGINS.has(event.origin)) return;
 
    // Type-narrow based on discriminated union
    const msg = event.data;
    if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
 
    switch (msg.type) {
      case "THEME_UPDATE":
        // handle...
        break;
      // ...
    }
  }
 
  window.addEventListener("message", handleMessage);
  return () => window.removeEventListener("message", handleMessage);
}, []); // Empty deps: listener is stable, no stale closures needed

TypeScript Discriminated Unions for Message Protocols

// Define all messages in a union. The "type" field is the discriminant.
type AppMessage =
  | { type: "NAVIGATE"; payload: { path: string } }
  | { type: "AUTH_TOKEN"; payload: { token: string; expiresAt: number } }
  | { type: "RESIZE"; payload: { width: number; height: number } }
  | { type: "READY" }; // no payload needed
 
// Type guard to validate unknown incoming data
function isAppMessage(data: unknown): data is AppMessage {
  if (typeof data !== "object" || data === null) return false;
  if (!("type" in data)) return false;
  const d = data as { type: string };
  return ["NAVIGATE", "AUTH_TOKEN", "RESIZE", "READY"].includes(d.type);
}
 
// Usage in handler
function handleMessage(event: MessageEvent) {
  if (event.origin !== EXPECTED_ORIGIN) return;
  if (!isAppMessage(event.data)) return;
 
  // TypeScript now narrows correctly in each case
  switch (event.data.type) {
    case "AUTH_TOKEN":
      // event.data.payload is { token: string; expiresAt: number }
      setToken(event.data.payload.token);
      break;
    case "NAVIGATE":
      // event.data.payload is { path: string }
      router.push(event.data.payload.path);
      break;
  }
}

Gotchas

  • Forgetting origin checks is the number one postMessage security vulnerability. Never skip event.origin validation, even in development. Build the habit early.
  • "*" as target origin broadcasts to any origin. Only use it for truly public, non-sensitive messages (and even then, think twice).
  • Messages are async. postMessage does not return a value. If you need a response, you must implement a request/response protocol with message IDs (covered in the intermediate guide).
  • event.source can be null if the sending window has been closed before your handler fires. Always null-check before replying.
  • Iframe must be loaded before you can send messages to it. Sending to contentWindow before the iframe's load event fires will silently fail. Use the iframe's onLoad callback or wait for a READY message from the iframe.
  • Multiple listeners can conflict. If you add a message listener in multiple components, every listener will fire for every message. Use the message type field to route to the correct handler.
  • Structured clone is not JSON. Structured clone preserves Date objects, Map, Set, etc., but it will throw on functions and DOM nodes. If you need to verify what is cloneable, use structuredClone() locally to test.
  • React StrictMode double-fires effects in development, which means your listener will be added twice (then cleaned up and re-added). This is fine because the cleanup function removes the old one, but be aware of it when debugging.

Alternatives

ApproachWhen to Use
BroadcastChannelSame-origin tabs or windows that need to share state (no cross-origin support)
Channel Messaging (MessageChannel)Dedicated two-way port between exactly two contexts (covered in advanced guide)
SharedWorkerMultiple same-origin tabs sharing a single worker for state coordination
Custom EventsCommunication within the same document (no cross-origin, no cross-frame)
Server-Sent Events or WebSocketWhen you need server-mediated communication between clients
URL fragment or query paramsOne-time data passing to an iframe (no ongoing communication)

FAQs

What is the first argument to window.postMessage and what types of data can it carry?
  • The first argument is the message payload, which is serialized via the structured clone algorithm.
  • Supported types include primitives, plain objects, arrays, Date, Map, Set, ArrayBuffer, Blob, File, and RegExp.
  • Functions, DOM nodes, Symbol, WeakMap, and WeakSet are not supported and will throw a DataCloneError.
Why must you always check event.origin in every message handler?
  • Without an origin check, any page that can iframe your page (or that your page iframes) can send arbitrary messages to your handler.
  • This is the number one postMessage security vulnerability.
  • Use a Set of known-good origins for fast, exact-match validation.
What happens if you use "*" as the targetOrigin?
  • The browser delivers the message to the target window regardless of its current origin.
  • If the iframe has navigated away, your message (potentially containing tokens or user data) goes to whatever page is now loaded.
  • Only use "*" for truly public, non-sensitive messages.
How do you type a postMessage handler in TypeScript using discriminated unions?
type AppMessage =
  | { type: "NAVIGATE"; payload: { path: string } }
  | { type: "READY" };
 
function handleMessage(event: MessageEvent<AppMessage>) {
  if (event.origin !== EXPECTED_ORIGIN) return;
  switch (event.data.type) {
    case "NAVIGATE":
      // event.data.payload is { path: string }
      break;
  }
}
How do you create a type guard for unknown incoming postMessage data in TypeScript?
function isAppMessage(data: unknown): data is AppMessage {
  if (typeof data !== "object" || data === null) return false;
  if (!("type" in data)) return false;
  const d = data as { type: string };
  return ["NAVIGATE", "READY"].includes(d.type);
}
Gotcha: What happens if you send a postMessage to an iframe before it has loaded?
  • The message is silently dropped because contentWindow is not ready.
  • Use the iframe's onLoad callback or wait for a READY message from the iframe before sending.
Is postMessage synchronous or asynchronous?
  • It is always asynchronous. The message goes through the event loop.
  • postMessage does not return a value. If you need a response, you must build a request/response protocol with correlation IDs.
Why should you use an allowlist Set instead of a regex for origin validation?
  • Regexes are easy to get wrong (e.g., unescaped dots, missing anchors) and can be bypassed.
  • A Set lookup is an exact string match, which is both safer and faster.
How does React StrictMode affect postMessage listeners?
  • StrictMode double-fires effects in development, so your listener is added twice.
  • The cleanup function removes the old listener, so it works correctly.
  • Be aware of this when debugging duplicate message logs in development.
What is the difference between structured clone and JSON serialization for postMessage?
  • Structured clone preserves Date, Map, Set, ArrayBuffer, Blob, circular references, and more.
  • JSON loses these types (e.g., Date becomes a string, Map becomes {}).
  • You do not need to JSON.stringify before sending or JSON.parse on receive.
Gotcha: Can event.source be null, and when does that happen?
  • Yes. If the sending window has been closed before your handler fires, event.source is null.
  • Always null-check event.source before calling postMessage on it to reply.
What is the correct useEffect cleanup pattern for postMessage listeners in React?
useEffect(() => {
  function handleMessage(event: MessageEvent) {
    if (event.origin !== EXPECTED) return;
    // handle message
  }
  window.addEventListener("message", handleMessage);
  return () => window.removeEventListener("message", handleMessage);
}, []);