Philip Tellis (@bluesmoon) is the Chief Architect of SOASTA where he works on the mPulse product to do Real User Measurement (formerly LogNormal). Philip writes code because it's fun to do. He loves making computers do his work for him and spends a lot of time trying to get the most out of available hardware. He is equally comfortable with the back end and the front end and his tech blog has articles ranging from Operating System level hacks to Accessibility in Rich Internet Applications.
In days of yore
Way back when, in the early days of web performance (ca. 2005), the suggested way to embed third party scripts in web pages, was to use a straight up <script>
node. This was simple, and what happened next was deterministic. The rest of the page blocked until this script node had loaded and executed.
<html>
<head>
<script src="http://some.site.com/script.js"></script>
<!-- And now we wait -->
</head>
<body>
</body>
</html>
This meant that if the script loaded successfully, any variables and functions defined in it, were available in the page immediately after. This made code easier to write. It also made for a terrible user experience.
Dynamic Script Nodes
Enter dynamic script nodes. Dynamic script nodes were not a new concept. We’d been using dynamic script nodes as a way to do JSON-P based remoting for a while. The concept was simple. Create a script node at run time, and set its src
attribute to the script you need loaded. The browser downloads the script in the background, and once downloaded, executes it using the regular JavaScript thread. If you’d like more details, Stoyan wrote about the async snippet in last year’s performance calendar.
<script>
var s = document.createElement("script"),
t = document.getElementsByTagName("script")[0];
s.src="http://some.site.com/script.js";
t.parentNode.insertBefore(s, t);
</script>
The background downloading is key. It meant that subsequent parts of the page didn’t block, waiting for the script to download and execute. This was great for user experience, but made code harder to write. You could no longer expect variables and functions defined within the script to be available after you’d initiated loading the script. In fact, there was no reliable, cross-browser way to know when this script had completed loading.
Three methods started to appear.
- Callback functions
- Polling for variables or functions
- The method queue pattern
The first method required you to define a function in your main HTML file, and then let your dynamically loaded script node call that function. This limited how easily the script’s exposed API could be used. ie, there was no way to call methods exposed by the script before the script was loaded, and you’d have to hack around cases where the script failed to load.
Polling had all the problems of callbacks, and all the problems associated with polling in general (which is an entirely different topic altogether).
The Method Queue Pattern
The method queue pattern was an interesting development. In this pattern, the script, and users of the script agree on a variable name. For example, google analytics uses the variable _gaq
. The API contract states that users of the script should define an array with a particular name, we’ll call it _mq
for the rest of this article.
In JavaScript, an array may be used to implement several basic data structures including stacks and queues. To implement a queue, you always use the push()
method to add elements to the queue, and always use the shift()
method to take them off.
So to use the Method Queue Pattern (MQP for short), a user of the script creates an array and starts pushing method calls onto it as strings:
var _mq = _mq || [];
var s = document.createElement("script"),
t = document.getElementsByTagName("script")[0];
s.src="http://some.site.com/script.js";
t.parentNode.insertBefore(s, t);
// script.js will be available some time in the future
// but we can call its methods
_mq.push(["method1", list, of, params]);
_mq.push(["method2", other, params]);
Each element pushed onto the queue is an array. The first element of the array is a string identifying the method while all subsequent elements are parameters to the method. The parameters can be of any datatype, including functions.
Once the script has completed loading, it looks for the array named _mq
, and starts reading and executing methods off the queue. Once it has completed reading elements off the queue, it redefines the _mq
object’s push()
method to directly execute the method rather than queueing it:
var self = this;
_mq = _mq || [];
while(_mq.length) {
var params = _mq.shift(); // remove the first item from the queue
var method = params.shift(); // remove the method from the first item
self[method].apply(self, params);
}
_mq.push = function(params) {
var method = params.shift(); // remove the method from the first item
self[method].apply(self, params);
}
The code above is somewhat simplified, but it does the right thing. Notice that there’s no way to return a value from any method called this way, and there’s no way to guarantee that the method will ever be called either. This isn’t a new problem though. Asynchronous method calls are the norm in event drive languages like JavaScript and we use callback functions to work with them. A method that needs to return a value will accept a callback function as one of its parameters. By convention, this is the last parameter passed in. Using the MQP, we just add this callback function as the last element of the array we push onto the queue:
_mq.push(["method3", "foo", 10, function(o) { console.log("We got", o); }]);
When the method3
method completes executing, it calls the callback function with its return value.
But we still block
At this point we have a way to download scripts asynchronously, call methods on the script without waiting for the script to finish downloading, and even get return values back from these methods through callbacks. We also don’t have to worry about our code throwing exceptions if the script doesn’t load (and if you’ve used progressive enhancement to build your site, it should still work perfectly).
Unfortunately, this isn’t the end of it.
It turns out that in most browsers, any resource that has started downloading before onload
, will block onload
. This means that if the script we loaded asynchronously was slow, or timed out, our onload
event would incur a significant delay. If your site does important tasks in the onload event (like load advertisements), these might be delayed, and might never execute if the user leaves the page before that happens, causing a loss in revenue. Every script added, whether directly or dynamically is a SPOF.
What we need is a way to download scripts asynchronously, without blocking the onload event, while still making its API available to code within the page.
One way to do this is to load the script itself in the onload event. Scripts loaded in the onload event do not block onload. However, it also means that none of the callback functions will execute until much after the onload event, and the script cannot perform any tasks that need to happen before onload (like measuring when the onload event fired, for example).
Who framed roger scriptlet?
A breakthrough was made in 2010, when the meebo team released the meebo bar. They noticed that an empty iframe wouldn’t block the onload event of the parent page even if you add content to the iframe at a later time. To be specific, once a resource’s onload event has fired, it is removed from the list of resources that block the page’s onload. An iframe’s onload event fires as soon as all content within the iframe has loaded. An iframe whose src
attribute is set to about:blank
or javascript:false
has no content, and its onload
event fires immediately. [See note below]
You can add content, including JavaScript, to the iframe, and anything loaded in or after its onload event, will not block the parent page’s onload event. We still need to use dynamic script nodes inside the iframe.
Stoyan refined the implementation for facebook, and David Murdoch refined
it further. After trying it out with boomerang, I made a few more changes resulting in this:
(function(url){
// Section 1
var dom,doc,where,iframe = document.createElement('iframe');
iframe.src = "javascript:false";
iframe.title = ""; iframe.role = "presentation"; // a11y
(iframe.frameElement || iframe).style.cssText = "width: 0; height: 0; border: 0";
where = document.getElementsByTagName('script');
where = where[where.length - 1];
where.parentNode.insertBefore(iframe, where);
// Section 2
try {
doc = iframe.contentWindow.document;
} catch(e) {
dom = document.domain;
iframe.src="javascript:var d=document.open();d.domain='"+dom+"';void(0);";
doc = iframe.contentWindow.document;
}
doc.open()._l = function() {
var js = this.createElement("script");
if(dom) this.domain = dom;
js.id = "js-iframe-async";
js.src = url;
this.body.appendChild(js);
};
doc.write('<body onload="document._l();">');
doc.close();
})('http://some.site.com/script.js');
I’ve split the code into two sections. Section 1 creates the iframe and adds it to the document. This is fairly straightforward code. Section 2 is where the magic happens. We create a JavaScript function in the iframe that loads up the script that we need, and then write HTML that runs this JavaScript on the iframe’s onload event.
The code is much larger than both, the simple script node and the dynamic script node code, but it’s completely non-blocking. The method has been called FIF, which stands for either Friendly IFrame, or Frame In Frame.
Of course, with this change comes added complexity. Our script now executes within an iframe, so all global variables that our script looks for are within that iframe’s context. This is a problem since the _mq
array we created is in the parent window. Additionally, we might face cross-domain issues.
I’ll address the cross-domain issues first.
Cross-domain issues
The interesting thing about setting an iframe’s src attribute to about:blank
or javascript:false
is if you check the value of location.href
inside an iframe with its src set to one of the above, you’ll get a value exactly the same as the parent frame. The only difference that I know of between the two is that the first is considered insecure content in IE6, so won’t work if your main page is over SSL.
With no other changes, the parent document and the iframe can communicate with each other without throwing a security exception.
There is however an issue if the main page sets document.domain
. This is true even if document.domain
is set to itself (document.domain=document.domain
).
document.domain is strange in that the state of a page (on IE at least) is different if this property is set implicitly or explicitly. If set implicitly (ie, by the browser based on the current page’s domain), then all other frames on that domain that also have it set implicitly can talk to each other. If set explicitly, however, then all other frames on that domain must also set document.domain
explicitly to the same value in order to communicate amongs themselves.
If the main page sets document.domain
, it makes it impossible for the main page to talk to our anonymous iframe, which includes writing the content into that iframe. So, how do you change document.domain
on a page if you cannot actually write any content into that page?
The trick is to write the JavaScript into the iframe’s src attribute. Instead of setting it to javascript:false
, we set it to JavaScript code that sets document.domain
, but only if document.domain
were explicitly set on the main page. We do this with the try/catch
block above.
Now this try/catch
block allows us to write content into the iframe, but on IE8 and below, document.domain
gets reset when we call doc.open()
inside the iframe. To get around that, we need to set document.domain
inside the iframe again just before adding our script node.
Also note that inside the onload handler, this
refers to the document
element. For some reason using document
in there doesn’t quite work.
This has worked with every configuration that we can think of to test, but if you find something that breaks it, please let me know.
Accessing the _mq
array
We also need to make a few changes to our script to get it to work from within the iframe, and since it’s likely that the script might be loaded synchronously as well, we still need to account for the non-iframe case.
Right at the top, our script needs to do this:
GLOBAL = window;
// Running in an iframe, and our script node's id is js-iframe-async
if(window.parent != window
&& document.getElementById("js-iframe-async")) {
GLOBAL = window.parent;
}
GLOBAL._mq = GLOBAL._mq || [];
_mq = GLOBAL._mq;
Notice a few things. We don’t use the var
keyword to declare the _mq
variable. This makes sure it is declared global within the iframe. Secondly, we make sure _mq
inside the iframe is an alias of _mq
outside the iframe, and is set to a valid array object. GLOBAL
may be an alias either to the current window or the parent window depending on whether the code is in an iframe or not.
Lastly, we check that a script node with an id of js-iframe-async
exists inside our iframe. This is important because we need to distinguish between on one hand, our script running inside an iframe that we created and on the other hand, our script running inside a page that is inside a larger iframe created by someone else. There are other ways to determine this, but setting an id on our script node is easy to do.
There are a few more things to note about the script running in an iframe. If it needs to attach to any in page events, or examine elements in the page, it needs to reference GLOBAL
and GLOBAL.document
instead of window
and document
. Of course, you could use your own namespace instead of calling it GLOBAL
. For example, the boomerang library uses BOOMR.window
instead.
We never shadow the window
and document
objects because we may need to use them either from the current frame or the parent depending on the use case.
For example, if boomerang needs to load additional plugins, it loads them using the window
object, but if it needs to load in-page resources, it loads them using the BOOMR.window
object.
We’ve been running this code in production for a month now, with some sites using it via the iframe method and others using it via the dynamic script node method. There has been no noticeable difference in the number of beacons before and after switching the code over.
LightningJS
I should make a special mention out to LightningJS from the guys at Olark. They’ve turned this pattern into a library that also implements the promises pattern. This allows you to call methods directly rather than using the MQP, however it requires a lot of inline JavaScript rather than the 15 lines we have above.
The non-blocking script loader pattern
So to summarise the pattern, this is what we do:
- Dynamically create an iframe with src set to
javascript:false
- If
document.domain
is set explicitly, then set it inside the iframe too. - Write HTML & JavaScript into the iframe that creates a dynamic script node for our script after the iframe’s onload event fires
- Set this script node’s id to
js-iframe-async
, or anything fixed that you prefer - Inside the script, check whether you’re running via the iframe pattern or not
- Create an alias to the global window object that points to the right window
- Create an alias to the global method queue array
- Do not shadow
window
ordocument
The state today
At LogNormal, we’ve made changes to boomerang (the opensource version as well as the one we serve to our customers) to work with all three loading patterns. We don’t use the method queue pattern yet, but that should come along soon. Stoyan’s post tells us that the Facebook Like button also uses the FIF technique. Meebo is now part of Google, so there’s a good chance that Google Analytics will go this way as well. Here’s hoping that other third party providers do so as well.
References
- Non-onload-blocking async JS by Stoyan Stefanov
- LightningJS by Olark
- The Async Snippet by Stoyan Stefanov
- The Meebo Bar by Meebo
- Frame in Frame at Facebook by Stoyan Stefanov
- Non onload blocking Async JS with RequireJS by David Murdoch
- W3C feature request for non-blocking scripts by Philip Tellis
Notes
- Since JavaScript in browsers is single-threaded, the iframe’s
onload
event will only actually fire when the function that creates it has completed and control has returned to the event loop. In our implementation, we execute thedocument.open()
,write
andclose
within the same function. This code executes before the iframe’sonload
event can fire. When theonload
event does fire, our handler has already been registered.
Updated 2013-02-13 to mention issues with document.domain
Updated 2013-02-21 we now have a feature request open for the W3C to add nonblocking
scripts to the HTML5 spec.