A Deep Dive into postMessage
window.postMessage is one of those browser APIs that almost every developer runs into at some point, usually when dealing with iframes or popups, and then immediately struggles to fully understand.
The first time I saw it, I knew one thing for sure: this wasn’t a network call. There were no HTTP headers, no server involved, nothing leaving the browser. But at the same time, data was clearly moving from one page to another. That made it feel like magic until I started digging into its functioning and that's when I realized that browsers truly are an engineering marvel!
This deep dive started when I needed to set up bidirectional communication between an iframe and its host application. But before getting into how postMessage works, it’s important to understand what we’re dealing with first - starting with the iframe itself.
What is an iframe?

An <iframe> (or, as textbooks love to call it, an Inline Frame) is an HTML element that lets you embed an entirely separate HTML document inside your page along with its own browsing context.
That browsing context isn’t just markup. It includes its own window object, execution stack, event loop, and JavaScript runtime.
Technically, an iframe is its own Window. It has its own memory heap, its own DOM, and its own global scope. Even though it looks like part of your page, from the browser’s point of view it’s a separate execution environment.
When the host page and the iframe don’t share the same origin (protocol, host, and port), the Same Origin Policy (SOP) prevents them from directly accessing each other’s JavaScript state or DOM. No shared variables. No DOM access. No reaching across the boundary.
<html>
<body>
<h1>Host Application</h1>
<iframe
id="embedded-widget"
src="https://secure-pay.io/checkout"
width="400"
height="200">
</body>
</html>This strict isolation is what keeps the web safe but it also introduces a practical problem: how do you get isolated worlds to talk to each other?
That’s where window.postMessage comes in.
What is postMessage?
window.postMessage() is the browser’s official API for cross-origin communication. It provides a way for isolated browsing contexts to exchange data without breaking the SOP.
Normally, when you embed an iframe from a different domain (say, your site is halfstackdevs.tech and the iframe is google.com), the browser treats them like complete strangers. They can’t see each other’s variables, memory, or DOM.
postMessage exists specifically because of this isolation. It gives these two contexts a way to exchange data without letting either side break out of its sandbox and violate SOP. Nothing is shared and nothing is exposed. The browser just relays messages when both sides explicitly allow it.
The High Level Flow
Let’s take an example of an application and an iframe embedded inside of it to understand how postMessage works.
1. The Sender Side (The Producer)
To send a message, you call the method on the target window (the one you want to talk to), not your own.
const targetOrigin = "https://safe-app.com";
const iframe = document.getElementById('my-iframe');
// payload
const data = { username: "hisham", action: "login" };
// optional: transferable objs (e.g., an ArrayBuffer)
const buffer = new ArrayBuffer(1024);
// dispatching data
iframe.contentWindow.postMessage(data, targetOrigin, [buffer]);data: The payload you want to send. The browser uses the Structured Clone Algorithm to create a deep clone of this object so it can be reconstructed in the receiver’s process, without either side accessing the other’s memory.
targetOrigin: the origin the receiver window(the iframe, in our case) must have in order to receive the event. In order for the event to be dispatched, the origin must match exactly. If the iframe is navigated (or hijacked) to a different origin than the one you specify, the browser silently drops the message. This is a security feature. Never use "*" unless you’re sending completely public, non-sensitive data. This argument is technically optional (default is "*"), but explicitly setting it is what actually secures the communication.
The Third Argument (Transferables): This is an array of objects you want to transfer instead of clone. In the example above, the ArrayBuffer is detached from the parent’s memory and attached to the iframe’s memory. No copies are made, which makes this both fast and memory-efficient.
2. The Receiver Side (The Consumer)
The receiving window doesn’t just magically “get” the data. It listens for a built in message event. This event is placed in the callback queue in receiver’s event loop and is only delivered once the current JavaScript execution stack is clear. (More on this later)
window.addEventListener('message', (event) => {
// 1. manual verification of who the sender is
if (event.origin !== "https://trusted-host.com") {
console.error("Unauthorized origin!");
return;
}
// 2. accessing deserialized data
console.log("Data received:", event.data);
// 3. responding back to the parent using the source handle
event.source.postMessage("Message received!", event.origin);
});
event.origin: The browser populates this automatically. It is the only way to verify the sender's identity.
event.data: This is the reconstructed object. It’s a deep copy of the original data, but it lives in the receiver’s memory heap.
event.source: This is a handle back to the sender. It allows the iframe to reply to the parent without needing to know/hardcode the parent's URL.
At this point, we’ve understood how postMessage is used and how messages flow at a high level.
But this still leaves the real question unanswered: How does this actually work under the hood?
That’s where things get interesting.
Under the Hood: How postMessage Works
To truly understand how postMessage actually works, you have to stop thinking about it as a JS function and start thinking about it as a cross process request.
Inside the browser, the parent window and the iframe are quite literally two different programs running on the operating system. They are isolated from each other by design. Let's do a deep dive into how data moves from one sandbox to another.
1. The Sender: Serialization & the Memory Wall
When you call postMessage(), you’re trying to send a JavaScript object. But here’s the problem: that object is just a collection of pointers to memory addresses that live inside Renderer Process A.
Because of process isolation, the operating system physically prevents Renderer Process B (the iframe / receiver) from reading those memory addresses. Even if it wanted to, it simply can’t.
To solve this, the browser runs the Structured Clone Algorithm.
This process “flattens” your object. The browser iterates over the data: strings, numbers, arrays, objects, and even circular references and converts everything into a serialized, binary buffer.
This is also why functions can’t be sent. A function isn’t just code but it’s tightly coupled to its surrounding scope (the closure), which simply can't be serialized.
Once flattened, the data becomes “dead data” - pure info with no pointers, no references, and no links back to the original process. In other words: a true deep clone.
2. The Broker: Browser Kernel & the IPC Transfer
Once your data is serialized into a binary buffer, it needs to leave the renderer sandbox.
But because of process isolation, one renderer(parent) cannot directly talk to another renderer(iframe). There’s only one way out: an already established OS pipe that connects the renderer to the Browser Kernel (this pipe is set up when the tab is first created).
In Chrome, this communication is handled by Mojo, Chromium’s internal IPC framework used for communication between browser processes. When postMessage is called, the renderer(parent) uses Mojo to send an IPC message to the Browser Kernel, which causes the OS kernel to copy the serialized buffer from Renderer Process A into the Browser Kernel’s address space.
At this point, the Browser Kernel acts as the privileged orchestrator.
Before routing the message to the receiver (the iframe in this case), it performs the origin check. It compares the targetOrigin you supplied with the actual origin currently loaded in the destination window. If they don’t match, the message is silently dropped.
If the origins do match, the kernel forwards the bytes to Renderer Process B using another OS pipe connected to that process.
3. The Receiver: The Event Loop Hand-off
Now the bytes are inside Renderer Process B. But the browser can't just "force" the Iframe to stop what it’s doing and run your code(event handling and reading the sent data). JavaScript is single-threaded and if the process is in the middle of a for loop (for example), interrupting it would cause unpredictable bugs.
Instead, the browser engine rebuilds the binary buffer, turning it back into a JS value/object in the receiver's own memory heap.
At this point, the browser does NOT run your handler yet. What it does instead is create a task record and push it into the receiver’s Task Queue (aka callback queue). This task contains deserialized payload, sender’s origin,and a reference to the sender window(handle).
The Event Loop keeps checking whether the call stack is empty. As long as some JS is running, the message just sits in the task queue untouched.
Once the execution stack becomes empty, the Event Loop takes this task off the queue and starts processing it. Using the data stored in the task, the browser then creates the MessageEvent object: it copies the payload into event.data, sets event.origin, and attaches event.source (source handle so the receiver can reply).
You can refer back to the snippets in the previous section to see how this maps to real code.
Only after the MessageEvent has been fully constructed does the browser call your registered message event listener and hand control back to JavaScript.
4. Transferables
If you’re sending something huge, like a 100 MB ArrayBuffer, serializing and cloning it would be slow and would briefly double your memory usage. That’s what the third argument of postMessage is for.
When you include a buffer in the transfer list, you’re telling the browser not to clone the data. Instead, ownership of that buffer is moved from the sender to the receiver.
Under the hood, the browser detaches the buffer from the sender (it becomes unusable there) and maps the same underlying memory into the receiver’s process. No data is copied but instead the OS just updates which process is allowed to access that memory.
And that’s it. Hopefully the next time you use window.postMessage, it feels a little less mysterious.
Thanks for reading, and feel free to reach out if you have any doubts or feedback. Happy learning!
