How Do I Use The postMessage Method With Cross-site Iframes?
Simmer Clips

- Simo Ahava
- May 2, 2023
- No Comments
The question we’re going to be looking at today is inspired by our course, JavaScript For Digital Marketers.
How do I work with third-party iframes when browsers block storage access in them?
The iframe
is the bane of the world wide web. It’s a technology used for embedding content from another page. Technically, an iframe
nests an entire, individual window
in the current page, with strict restrictions on what can happen within the boundaries of the parent and the embedded content.
One of the biggest restrictions relates to storage access. If the iframe
loads its content from a cross-site origin (i.e. the main domain of the iframe
is different from the main domain of the page embedding it), then any storage, such as cookies, will be accessed in third-party context.
Because so many browsers today block third-party cookies, this means that trying to run something like Google Analytics 4 in a cross-site iframe is very, very unreliable.
For that reason, it’s better to have the iframe
communicate messages to the parent frame with postMessage
, and then the parent frame can relay these to vendor services that would not work if run directly in the iframe
itself.
Video walkthrough
If you prefer this walkthrough in video format, you can check out the video below.
Don’t forget to subscribe to the Simmer YouTube channel for more content like this.
If you want to skip the theory (it’s much of the same content as you find in the video), you can jump straight to the Google Tag Manager / Google Analytics 4 solution by clicking this link.
The iframe relays its events to the parent page
Because setting up tracking within the iframe is hampered by browsers preventing access to storage, we need to make sure the iframe works in “cookieless” mode.
This means that the iframe itself needs to operate without storage access. But how do we then collect data from the iframe? We need to associate that with the user, and that requires storage access.
The one context that should always have storage access is the page the user is currently navigating on. So, we need the embedded iframe to relay its happenings and goings-on to the top-most frame, which can then track events on the iframe’s behalf.
Naturally, the metadata sent from the iframe to the top page needs to include everything relevant to the outgoing data stream. With Google Analytics 4, this would include things like the iframe page URL and title together with any of the actual event and user data the iframe event is dispatched with.
The events collected in the iframe need to be available without storage access. Thus, events that require Google Analytics 4 (such as those created in the GA4 settings) will not be available in the iframe.
In this article, I’ll provide a quick overview of window.postMessage
before sharing a setup you can use to collect dataLayer
interactions that happen in the iframe and process them in the parent.
How the postMessage channel works
The window.postMessage
API requires two components.
First, in the receiving end, you need to create an event listener that detects the events sent with postMessage
. In the listener callback you can then process these events.
In the dispatching end, you need to run the targetWindow.postMessage()
calls themselves, so that the receiving end can capture those events.
The receiver / event listener
window.addEventListener('message', function(event) {
...
});
The listener listens for the message
event name, which is automatically used by postMessage
. The second argument to addEventListener()
is the callback function, which automatically populates an event
object with details about the MessageEvent
, including, for example, where the message originated from and what the contents of the message were.
It’s a best practice to check the event.origin
so that it matches the expected source’s URL origin.
There are lots of services that dispatch messages with postMessage
, and not all of these are benign.
Here’s a complete example of an event listener that could run on the parent page, waiting for messages from an embedded iframe:
var allowedOrigins = ['https://www.known-iframe.com', 'https://another.known-iframe.com'];
var handleMessage = function(event) {
// Abort if request doesn't come from a valid origin
if (!allowedOrigins.includes(event.origin)) return;
// Log the message contents to the console
console.log(event.data);
};
window.addEventListener('message', handleMessage);
You can manipulate event.data
however you wish. Naturally, you need to be aware of what the possible format of the message is before you assume it’s JSON or a plain object, for example.
The event dispatcher
The dispatching end is a bit more complex to set up.
Whereas the event listener is generic, in that it doesn’t need to know when and where the events come from, the dispatcher needs to know exactly where to dispatch the events to.
First, here is the signature of the dispatch mechanism:
targetWindow.postMessage(message, origin);
The targetWindow
needs to be a reference to the window to which you are dispatching the messages. If the messages are sent from the iframe to the top (the window the user is navigating), you’d use something like window.top.postMessage(...)
.
But if you want to dispatch a message from the top to an embedded iframe, you need to first fetch a reference to the iframe element and then access its contentWindow
property. Like this, for example:
var targetIframe = document.querySelector('iframe[src*="known-iframe.com"]');
if (!!targetIframe) {
targetIframe.contentWindow.postMessage(...);
}
You also need to know the URL origin of the target window. It’s not enough to just have a reference to the window itself.
For example, if the iframe is dispatching messages to the top frame and the URL origin of the top frame is https://www.teamsimmer.com
, the dispatcher would be run like this:
window.top.postMessage(message, 'https://www.teamsimmer.com');
You can replace the URL origin string with an asterisk: '*'
. This dispatches the message regardless of the target window URL origin.
I recommend always setting the origin
explicitly if you know it. That way you’ll ensure you don’t send information to locations you are not aware of.
The message itself can be any serializable JavaScript type. However, the setup will throw an error if you try to dispatch a Function
or a DOM node, for example, so it might be better to handle the serialization yourself.
Here’s an example of a message dispatcher that sends a simple object, serialized into JSON, to the top-most parent on https://www.teamsimmer.com
.
// Only run if in an iframe
if (window.top !== window.self) {
window.top.postMessage(
// Serialize the message
JSON.stringify({
event: 'gtm.click',
'gtm.elementUrl': 'https://www.simoahava.com',
pageUrl: document.location.href,
pageTitle: document.title
}),
'https://www.teamsimmer.com'
);
}
Simple Google Tag Manager / Google Analytics 4 channel
In this latter part of the article, I’ll show you how to set up a listener and dispatcher specifically for Google Tag Manager and, by extension, Google Analytics 4.
The idea is simple: there is a Custom HTML tag running in the GTM container of the parent page that listens for messages dispatched from the iframe. These messages are then written into the parent page’s dataLayer
so that GTM tags can make use of them.
In the GTM container of the iframe page, there’s an event dispatcher that monitors dataLayer
messages and forwards them to the parent page whenever they occur. In other words, the iframe forwards all of its dataLayer
messages to the parent, and the parent can then handle them however it likes.
Note! You don’t need Google Tag Manager for either component. You can run both the parent page Custom HTML tag and the iframe Custom HTML tag (or one or the other) directly in the site code, too.
Parent page Custom HTML tag
The Custom HTML tag of the parent page is very simple. All it has to do is check the message came from an expected source and that it has expected contents, and then parse these contents and push them into the parent page’s dataLayer
array.
You can set this listener to trigger as early as you like, for example on the Initialization – All Pages trigger.
The callback first checks if the message comes from a known origin (line 15), before parsing the JSON string back to an object and pushing it to dataLayer
(lines 19–22).
Remember to list the allowed origins in the array on line 8.
The iframe Custom HTML tag
To make things a bit simpler, we’re going to base the following solution on a couple of assumptions:
- The messages are dispatched to the window the user is navigating (
window.top
). If you instead want to dispatch it to another iframe or similar, you need to use DOM methods to find the reference following the instructions earlier in this article. - The iframe is loaded after the Google Tag Manager container has loaded. This is a timing issue, and you can read more about it below (under the heading Timing issues).
- The dispatcher only forwards plain objects in
dataLayer
– it doesn’t touch those generated from object instances (e.g. from GTM’s custom templates), nor does it handle functions or arrays.
Here’s what the Custom HTML tag would look like:
A bit more intricate than the receiver, don’t you think? Well, here’s the gist.
This dispatcher needs to trigger once. You can set it up on the Initialization – All Pages trigger, for example.
On line 10,you need to define the URL origin of the top frame (the one you’ll dispatch events to).
On line 15, you can list all the events you want to exclude from the dispatcher. By default, I’ve listed the three page load events of GTM because they rarely have any utility themselves, but you can just set var excludeEvents = [];
if you don’t want to exclude anything.
On line 25, you can choose whether to send the full accumulated dataLayer
history upon init. This is a good idea if you have lots of stuff before the container snippet in the dataLayer
that you want to dispatch to the parent, too.
On line 31, you can define what the “namespace” of these events are when relayed to the parent. By default, the event name is prefixed with iframe.
, so an event like gtm.click
becomes iframe.gtm.click
. Similarly, any keys in the object itself are wrapped in the namespace, too, so pageUrl
becomes iframe.pageUrl
.
The rest of the code deals with the intricacies of creating the dispatcher itself.
In this case, we overload the dataLayer.push
method with our own custom listener. Check this article for more details about this process.
Any time a plain object is pushed into dataLayer
, it is first relayed to the top window before being parsed by Google Tag Manager in the iframe.
Verify and process the messages
If the setup works, then for each dataLayer
message (that does not come with an excluded event) in the iframe, you should see a corresponding message in the parent dataLayer
, just wrapped in the namespace you defined in the dispatcher settings.
Honestly, there are so many moving parts here that I don’t blame you if it doesn’t work with the first try. Just make sure you copied the code correctly and that you configured all the configurable settings in both the parent page setup and the iframe setup.
Once the setup works, you can then create tags for these messages in the parent page. You can create variables for all the messages that originate from the iframe by making use of the iframe.
prefix. Similarly, you can build your Custom Event triggers with the iframe.
event name prefix, too.
By setting the page_location
field in your GA4 tag to the {{iframe.pageUrl}}
Data Layer variable, and the page_title
field to {{iframe.pageTitle}}
, it’s as if the events actually originated from the iframe (which was the goal of this entire exercise!).
Timing issues
If you read this article carefully (I don’t blame you if you didn’t!), you’ll remember that for postMessage
to work properly, the listener needs to exist before any messages are sent.
In the case of iframes, you need to make sure that the iframe runs its postMessage
calls after the parent page has set up the addEventListener
hook. This is particularly true if you use the solution I shared above, where the entire history of dataLayer
is messaged only upon initialization and not again later.
To combat timing issues, you can build a more elaborate, bilateral messaging channel.

In this article on Simo Ahava’s Blog, I share a more elaborate solution on how to build a messaging setup like the one above.
It’s just as complex as it looks, and ideally you wouldn’t have to worry about this. If you don’t care about collecting the history of the dataLayer
from the iframe upon initialization, you can simply use what I shared in this article.
Summary
Almost ten years ago, I wrote these words in an article on my personal blog.

Nothing’s changed in my attitude towards iframes in the time that’s passed! They’re still an inconvenience. Far too often, they represent a lazy developer somewhere, who instead of building a proper integration just decided to ship the service in an embedded window.
Yes, there are scenarios where iframes are (almost) unavoidable, but in those cases it would be great if the embedded service offered a native JavaScript postMessage
API (similar to what, for example, YouTube does) instead of sites having to build their own setup.
Having said all that, I hope this article helped demystify postMessage
. It really is a very well-oiled API, and I hope you agree that it’s not as complicated as it might have seemed before you started reading this article.
You really need just two components: a receiver and a dispatcher, and both can be built with very little effort.
Please let me know in the comments if you have questions or comments after reading this article!
As always, thank you for your undivided attention.