How Do I Use The postMessage() Method With Cross-site Iframes?

In this article, we take a look at the window.postMessage() method and how it can be used to dispatch messages between two windows or frames.

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.

video
play-rounded-fill

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.

Iframe to top to ga4


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

The event listener is very simple. The signature looks like this:

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.

URL origin
The URL origin refers to the URL string trimmed down to just the protocol, hostname, and possible port. So for a URL like https://www.teamsimmer.com/contact/, the URL origin would be https://www.teamsimmer.com.

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.

<script>
  /**
   * postMessage forwarder for dataLayer events
   * parent page component
   */
  (function() {
    // List all the origins that might send dataLayer messages to this listener
    var allowedOrigins = [
      'https://www.some.origin',
      'https://some.other.origin'
    ];

    var handleMessage = function(event) {
      // Validate the origin
      if (!allowedOrigins.includes(event.origin)) return;

      // Only handle JSON
      try {
        var msg = JSON.parse(event.data);

        // Push the message object to dataLayer
        window.dataLayer.push(msg);
      } catch(e) {}
    };

    window.addEventListener('message', handleMessage);
  })();
</script>

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:

  1. 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.
  2. 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).
  3. 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:

<script>
  /**
  * postMessage forwarder for dataLayer events
  * iframe component
  */
  (function() {
    /**
     * Replace this with the URL origin of the top window
     */
    var topOrigin = 'https://www.parent.origin';

    /**
     * List the events that should be excluded from dispatch
     */
    var excludeEvents = [
      'gtm.js',
      'gtm.dom',
      'gtm.load'
    ];

    /**
     * Set to true to send the full dataLayer history to the top window
     * upon initialization
     */
    var sendHistoryUponInit = true;

    /**
     * Set the event name prefix and dataLayer namespace
     * for messages emitted from this iframe.
     */
    var dataLayerNamespace = 'iframe';

    /**
     * Solution code begins
     */

    /** Break if not in an iframe */
    if (window.top === window.self) return;

    var sendToTop = function(obj) {
      /** Only send plain objects */
      if (obj.constructor !== Object) return;
      if (excludeEvents.includes(obj.event)) return;

      // Set page URL and page title as default keys
      obj.pageUrl = obj.pageUrl || document.location.href;
      obj.pageTitle = obj.pageTitle || document.title;

      var wrappedObj = {};
      wrappedObj[dataLayerNamespace] = obj;
      /** If there is no event name in the object, use "message" */
      wrappedObj.event = dataLayerNamespace + '.' + (obj.event || 'message');

      window.top.postMessage(JSON.stringify(wrappedObj), topOrigin);
    };

    /** Send dataLayer history upon init */
    if (sendHistoryUponInit) {
      window.dataLayer.forEach(function(msg) {
        sendToTop(msg);
      });
    }

    /** Overload the dataLayer.push() method with a custom handler */
    var oldPush = window.dataLayer.push;
    window.dataLayer.push = function() {
      var states = [].slice.call(arguments, 0);
      states.forEach(function(state) {
        sendToTop(state);
      });
      return oldPush.apply(window.dataLayer, states);
    };
  })();
</script>

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. 

bilateral messaging between the parent and the iframe
Source: Cookieless Tracking For Cross-site Iframes (Simo Ahava’s Blog)

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.

17 Responses

  1. Hi. Am working my way through this. Do I need two GTM containers (one for the parent, one for the child)? Or can I just use one container? My scenario is a main site with a shopping cart platform hosted in an iframe. Therefore, nothing much needs to be recorded separately for the shopping cart (no direct traffic per se). So unless there is a technical reason to have two containers, I am thinking one container will do, GA4 and Parent Listener set to fire on the parent hostname initialisation, Child Dispatcher on the child hostname initialisation?

    1. Hi

      You can use two containers or you can use just one and the same container. Naturally, with one container you might need to add some triggers and exceptions to block tags that shouldn’t run in the iframe’d site.

  2. Hi Simo,
    Thanks for this clear article!
    We lately implemented a tracking plan in our app, with GTM and GA4. Everything is working perfectly. The problem started when our app was integrated as an iframe in another site (with another domain name) . the GTM is firing the events , and the dataLayer receives everything, but no hits are sent to google analytics. (no collect request are sent in the network). after reading your article I consider it is related to third-party cookies…
    we do not have access to the parent site code. so I cannot implement your solution.
    the parent domain has also a GTM connected (and there it’s working and sending hits to google analytics).
    Our app is used iframed and not, so the tracking has to handle both cases.

    is there any option to send hits to ga4 from within the iframe??

    do you have any solution?
    thanks!

    1. Hi

      Not really. GA4 requires cookies to function, and if it’s a cross-site iframe embed the browser needs to support 3P cookies. Thus in many browsers (and soon perhaps Google Chrome, too), GA4 will no longer work in cross-site iframes.

  3. Hi Simo,

    Thank you for this article

    I’ve a question around the consent. If we want to implement NEW GTM container on iframed site(which is different from the one which is present on child) for tracking purpose (without using the custom codes of posting and listening) messages then how to handle consent mode here. Can we implement consent mode on this iframed pages exclusively inorder to track the events directly from iframe? If yes, will it appear only when the iframe is opened as a seperate page and not as an iframe embed?

    Also, to stitch the data that all these events are from same page (both parent and iframe events) – Using same data stream for these two container would be enough I guess. Am I right?

  4. Hi, thank you for this solution. I can get events such as iframe.gtm.js, iframe.gtm.scrollDepth, iframe.historyChange-v2, etc., but I keep also getting iframe.gtm.pageError, especially on a specific click. The error message is this:
    `Uncaught TypeError: Converting circular structure to JSON
    –> starting at object with constructor ‘Object’
    | property ‘blueprint’ -> object with constructor ‘Array’
    — index 1 closes the circle`
    What is causing this error, and do you have any recommendations for fixing it? Thank you!

  5. What If in the iframe I’d like just to do a simple dataLayer push, lets say on form submission like:

    dataLayer.push({
    ‘event’: ‘formSubmitted’,
    ‘pageLocation’: ‘window.location.href’,
    ‘pageTitle’: ‘document.title’
    });

    What do I need from the 2nd code to load? Do I need to load GTM code at all?

    1. Hi

      If you do that, then you need GTM in the iframe as well as a tag that takes that dataLayer content and sends it with postMessage to the parent page, as instructed in this article.

  6. I’m trying to make something similar, but I’d rather just bring a few specific items to the data layer. I’ve been trying to make this code work for a few days, but I can’t see what I’m doing doing wrong. I think I might be doing the JSON bit wrong. Any ideas?

    Iframe code:

    // Function to send form data to the parent frame
    window.addEventListener(“load”, function() {
    conversionSignal();
    });

    function conversionSignal() {
    // Key-Value pairs for Asset Type
    var conversionAsset = {
    ‘guide-1’: ‘guide-conversion’,
    ‘guide-2’: ‘guide-conversion’,
    ‘guide-3’: ‘guide-conversion’,
    ‘guide-4’: ‘guide-conversion’,
    ‘guide-5’: ‘guide-conversion’,
    ‘appointment’: ‘appointment-conversion’
    };

    // Check if the conversion type is found
    if (!conversionAsset[‘%%src{js}%%’]) {
    console.error(‘Conversion type not found. Stopping the function.’);
    return; // Stop the function execution
    }

    // Create an object with the conversion type
    var conversionData = {
    ‘conversion-type’: conversionAsset[‘%%src{js}%%’],
    };

    // Add email property if it exists
    if (‘%%email%%’) {
    conversionData[‘conversion-email’] = ‘%%email%%’;
    }

    // Add phone property if it exists
    if (‘%%Cell_Phone%%’) {
    conversionData[‘conversion-phone’] = ‘%%Cell_Phone%%’;
    }

    // Send the form data as a JSON string to the parent frame
    window.parent.postMessage(JSON.stringify(conversionData), ‘https://example.com’);
    }

    Parent code:

    window.addEventListener(‘message’, function(event) {
    if (message.origin === ‘https://example.org’) {
    try {
    var conversionData = JSON.parse(event.data);
    window.dataLayer = window.dataLayer || [];
    var dataLayerEntry = {
    ‘conversion-type’: conversionData[‘conversion-type’]
    };
    if (conversionData[‘conversion-email’] !== undefined) {
    dataLayerEntry[‘conversion-email’] = conversionData[‘conversion-email’];
    }
    if (conversionData[‘conversion-phone’] !== undefined) {
    dataLayerEntry[‘conversion-phone’] = conversionData[‘conversion-phone’];
    }
    dataLayer.push(dataLayerEntry);
    } catch (e) {
    console.error(‘Error parsing the form data:’, e);
    }
    }
    }, false);

    1. Hi

      It’s difficult to say what’s wrong because you didn’t say what’s wrong. Where does the code stop working? You’ve added console.log() commands there, which ones don’t show up? Do you see errors in the console? I also don’t understand what this ‘%%email%%’ etc. syntax is and why it should work in a Custom HTML tag (I’m assuming this is running in a Custom HTML tag).

      1. I apologize for wasting your time. I was trying not to waste your time by getting into the details of my use case, but I did the opposite.

        Fortunately, I took the time to slow down and really go through my code and your helpful examples and I was able to fix my issues.

        My original comment does nothing to add to the discussion, so you can delete this thread.

        I’m considering checking out your javascript for digital marketers class when I have time because I clearly need to improve my skills. 🙂

        1. Hey Mike!

          No worries at all – you didn’t waste my time and I’m glad you found the solution!

  7. This is such an elegant solution. I’m hopeful this is going to help me with a project I’m working on (I’m still waiting to get GTM on the partner’s iframed site). I have one question in the meantime…

    Our iframed site currently doesn’t have anything in its dataLayer so presumably I should add a GA data stream to that site as well so its dataLayer is populated with user events etc… The question is whether this should be the same data stream as the parent site, or a different one so as not to double up? Should it even be under the same GA property?

    Apologies if that’s a silly question!

    1. Hi

      Since GA4 doesn’t run properly in a cross-site context you absolutely do not need to have GA4 running on the iframed site and in many browsers (that block 3P cookie access) it won’t even work. If you wish, you can run GTM in the iframed site, together with triggers that push information into dataLayer (e.g. Click, Form) which are then postMessaged back to the parent frame.

Thoughts? Comment Below 👇

Your email address will not be published. Required fields are marked *

More from the Simmer Blog

In this article, I'll show you how you can trigger a BigQuery scheduled query as soon as the Google Analytics 4 daily export job is complete.
In this blog post, we'll take a look at how you can change the default table expiration and the table-specific expiration in Google BigQuery.
How to assign a static IP address to a subset of outgoing requests from a server container. This is useful if a vendor needs to allowlist the IP addresses of incoming requests.
Hide picture