Web browsers expose requestAnimationFrame to allow developers to register a callback that they wish to be invoked before the next browser repaint. This is frequently used as an efficient way to update an on-screen animation. However, if you have embedded hidden iframes you cannot rely on this method being called. We will investigate the behaviour in this blog post.

Introduction

A responsible web developer who wants to apply custom animation to a web page will likely come across requestAnimationFrame. This allows the developer to register a callback and request that the browser executes this callback before the next repaint. You can call it in this manner:

    
window.requestAnimationFrame(function(timestamp){
  // animate something
});
    
  
The timestamp value passed to the callback is an instance of DOMHighResTimeStamp, which is effectively the time in milliseconds since the start of the current document's lifetime.

An animation is typically something that we want to update rapidly, but not constantly. We just want to update at a rate that appears smooth to the user, without burning more CPU than is necessary. For humans a rate of 60 frames-per-second is considered to be the limit that most of us can detect, so if you are updating your animation with something approaching this frequency then it should appear smooth. So why don't we just use setTimeout or setInterval to do all of our animations at this rate?

The benefit of requestAnimationFrame is that the developer is explicitly revealing their intentions to the browser; namely that this callback is related to animation. With setTimeout or setInterval the browser has no indication of what we intend to execute in our callback. By expressing our intent we allow the browser to optimize the animation (e.g. combining multiple animations) before a single repaint. This also allows the browser to make decisions about whether the animation callback should even be run. For example, if your tab has been moved to the background then running the animation loop is probably a waste of processor time, so the browser can choose not to run the animation, saving processor cycles and the environment :)

So what's the catch? There isn't one, unless you have an animation that you need to run, but the browser decides that it doesn't want to run it. Read on to find out more.

The problem: Hidden embedded iframes

At GoConqr we offer a set of tools for creating learning content . One of the most popular tools is MindMaps. When a user chooses to print their mindmap we make an AJAX request for a specially prepared version of the mindmap from the server, we load this into a hidden iframe on the page and then execute the print from that iframe. Before printing, the hidden iframe needs to execute JS to build the mindmap on the canvas, and some of this rendering makes use of requestAnimationFrame.

This little dance happens unbeknownst to the user, who will just see the result of the print action. Things had worked this way for years without any serious issue. However, more recently we have done some work on integrating the GoConqr tools with Microsoft Teams. With our mindmaps now loaded within a frame in MS Teams the print function suddenly stopped working. After some investigation we zeroed-in on the culprit: requestAnimationFrame and it's behaviour when used inside an embedded iframe.

Example: Using requestAnimationFrame within an iframe

Let's start by considering the frame that we want to embed, it is a simple HTML page with a script that uses requestAnimationFrame to execute some dummy animation:

    
    
  <head></head>
  <body>
    <p>This is my frame</p>
    <ol id="log_messages"></ol>
    <script>
      let start_time = null;
  
      const $message_container = document.getElementById("log_messages"),
      log_message = function(msg){
        const $msg = document.createElement("li"),
        $text = document.createTextNode(msg);
        $msg.appendChild($text);
        $message_container.appendChild($msg);
      },
      animate_continuously = function(timestamp){
        if(!start_time){
          start_time = timestamp;
        } else if(timestamp-start_time>500) {
          log_message("Finishing animation");
          alert("Animation done");
          return;
        } else {
          log_message("Animating: " + timestamp);
        };
        requestAnimationFrame(animate_continuously);
      };
  
      document.addEventListener("DOMContentLoaded", function(e){
        log_message("DOMContentLoaded");
        log_message("Starting animation");
        animate_continuously();
      })
    </script>

  <body>
</html>
    
  
The script in the frame registers a handler for the DOMContentLoaded event. Within that handler we call the animate_continuously function, which registers itself as a callback using requestAnimationFrame. So each time our animate_continuously callback is executed by the browser, we will enqueue the next invocation using requestAnimationFrame. The animation function doesn't really do anything apart from logging message to the page. We run the animation for 500ms and issue a browser alert once the animation is complete.

In our subsequent examples we will embed this page as an iframe. To support the examples I have hosted this page frame.html at two separate locations:

  1. On the current domain: here.
  2. In an publicly accessible S3 bucket: here.

Loading the frame from these different locations we will contrast the impact of using requestAnimationFrame within iframes when they are loaded from the same domain or a different domain. For each example we will link you out to a separate simple page on this domain to cleanly demonstrate the effect.

The original problem was observed in Chrome, so for the examples below we will focus on the behaviour in Chrome. However, from our testing, this behaviour can also depend on browser vendor. So if you are seeing something different, jump to this section to see if your observations are consistent with ours.

Visible iframe served from the current domain

The first case we will look at is if we just embed this frame directly into a page, like this:

      
      
    <div class="frame-wrapper" style="border: 2px solid #ccc;height: 300px; width: 80%;">
      <iframe width="100%" scrolling="auto" src="https://vector-logic.com/blog-support/on-request-animation-frame/frame.html" style="display: block">
      </iframe>
    </div>
      
    
You can visit this simple page on this domain by clicking the link below:

If you hit the button, you should have seen the nested iframe logging output from the animation loop and issuing an alert when complete. Cool.

Hidden iframe served from the current domain

Now if we look back at the MDN Docs on requestAnimationFrame we see the following:

requestAnimationFrame() calls are paused in most browsers when running in background tabs or hidden <iframe>s in order to improve performance and battery life.

This looks interesting. This might be a potential reason for the print function not working on GoConqr. However, this functionality has relied on a hidden iframe from day zero, so it doesn't fully explain what we are seeing. Let's investigate further by taking our previous iframe, but this time we will embed it on the page with style="display: none", like so:

      
      
    <div class="frame-wrapper" style="border: 2px solid #ccc;height: 300px; width: 80%;">
      <iframe width="100%" scrolling="auto" src="https://vector-logic.com/blog-support/on-request-animation-frame/frame.html" style="display: none">
      </iframe>
    </div>
      
    
If you click the link below you can view this simple page. Will it trigger the animation?

If you visit this page you will hopefully have noticed that the logs were not displayed. This is because the iframe containing the logs is hidden. You can use the console to inspect the DOM and you should see that the logs have, indeed, been generated. Nonetheless, the alert will have been issued to indicate that the animation successfully completed .

This behaviour in Chrome a bit surprising; the MDN documentation had given us the impression that the browser may not run requestAnimationFrame callbacks when they are associated with hidden iframes. However, in our simple test Chrome continues to execute the requestAnimationFrame callbacks for this hidden frame. (Note: A different behaviour is observed for Firefox).

So if requestAnimationFrame is firing for hidden iframes, how can we explain our problems with mindmap printing? Perhaps the domain from which the iframe is loaded is significant?

Visible iframe served from a different domain

In the next test, instead of loading our iframe from the same domain as the parent page, we will try to load it from a different domain. As explained earlier, we can achieve that by hosting the same frame.hml file on our S3 bucket (domain vector-logic-blog.s3.eu-west-1.amazonaws.com). The frame will then be included on the page as follows:

      
      
    <div class="frame-wrapper" style="border: 2px solid #ccc;height: 300px; width: 80%;">
      <iframe width="100%" scrolling="auto" src="https://vector-logic-blog.s3.eu-west-1.amazonaws.com/on-request-animation-frame/frame.html" style="display: block">
      </iframe>
    </div>
      
    
To visit this page click the link below. Note the page is still hosted on this domain, but the iframe is loaded from S3:

Again we see the animation writing logs to the frame and an alert is issued at the end of the animation to indicated that it successfully completed. So we can run the animation when the iframe is loaded from another domain, but what if that iframe is hidden?

Hidden iframe served from a different domain

In a simple extension to the last example let's hide the embedded frame, i.e.

      
      
    <div class="frame-wrapper" style="border: 2px solid #ccc;height: 300px; width: 80%;">
      <iframe width="100%" scrolling="auto" src="https://vector-logic-blog.s3.eu-west-1.amazonaws.com/on-request-animation-frame/frame.html" style="display: none">
      </iframe>
    </div>
      
    
Again, click the link below to view this page:

Hopefully if you clicked this last button you would notice that no alert was fired. The animation has not run in this case. This was the root of our problem with mindmap printing. Using requestAnimationFrame within a hidden iframe never caused us a problem, but when this was embedded within another domain (i.e. within MS Teams) then the requestAnimationFrame callbacks failed to run. Can we solve the problem in this case?

Variations across browser

Thus far we have been mainly concerned with the behaviour exhibited by Chrome. However, as alluded to before, this behaviour shows some variation across browser vendors. From our limited testing this is what we have observed:

Visible same domain Hidden same domain Visible other domain Hidden other domain
Chrome
Firefox
Edge
Safari

The solution: Back to setTimeout

One solution is to avoid reliance on requestAnimationFrame in such cases. We can rewrite the frame.html slightly to revert back to an implementation using setTimeout instead:

     
         
    <p>This is my frame</p>
    <ol id="log_messages"></ol>
    <script>
      let start_time = null;

      const $message_container = document.getElementById("log_messages"),
      log_message = function(msg){
        const $msg = document.createElement("li"),
        $text = document.createTextNode(msg);
        $msg.appendChild($text);
        $message_container.appendChild($msg);
      },
      requestFrame = function(callback){
        setTimeout(function(){
          callback(performance.now());
        }, 1000/60);
      },
      animate_continuously = function(timestamp){
        if(!start_time){
          start_time = timestamp;
        } else if(timestamp-start_time>500) {
          log_message("Finishing animation");
          alert("Animation done");
          return;
        } else {
          log_message("Animating: " + timestamp);
        };
        requestFrame(animate_continuously);
      };

      document.addEventListener("DOMContentLoaded", function(e){
        log_message("DOMContentLoaded");
        log_message("Starting animation");
        animate_continuously();
      })
    </script>
     
    
In this case we define the requestFrame method ourselves, which uses the setTimeout method to enqueue the animation at our desired frame-rate of 60 times per second. Note we also use the performance.now() to generate a timestamp to pass into the callback. See the MDN documentation for DOMHighResTimeStamp:
You can get the current timestamp value—the time that has elapsed since the context was created—by calling the performance method now()

We define our final page to load this updated frame_fixed.html from our S3 bucket into a hidden iframe:

      
      
    <div class="frame-wrapper" style="border: 2px solid #ccc;height: 300px; width: 80%;">
      <iframe width="100%" scrolling="auto" src="https://vector-logic-blog.s3.eu-west-1.amazonaws.com/on-request-animation-frame/frame_fixed.html" style="display: none">
      </iframe>
    </div>
      
    
Click the link to see the result: Hoepfully when you visited this page you will not see any logs, owing to the frame being hidden, but you should get a browser alert informing you that the animation is complete. Success.

Conclusion

If you are using requestAnimationFrame you may find that your callbacks will fail to run from within a hidden iframe. This behaviour also seems to be fundamentally influenced by whether the iframe is loaded from the same domain as the containing document, and the behaviour varies across browsers. If you have code that must run in a hidden iframe you might need to consider switch from using requestAnimationFrame to the old reliable setTimeout.

References

  1. MDN Docs
  2. Original introduction by Paul Irish
  3. DOMHighResTimeStamp
  4. GoConqr MindMaps

Comments

There are no existing comments

Got your own view or feedback? Share it with us below …

×

Subscribe

Join our mailing list to hear when new content is published to the VectorLogic blog.
We promise not to spam you, and you can unsubscribe at any time.