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

Simmer Clips

How do I use the postMessage method with cross-site iframes?

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

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.

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.

Leave a Comment

Hide picture