Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save vasanthk/079a9332b53656c3ea2aecb9797835c1 to your computer and use it in GitHub Desktop.
Save vasanthk/079a9332b53656c3ea2aecb9797835c1 to your computer and use it in GitHub Desktop.
Beware of "script async defer" blocking HTML "load" event

Beware of <script async defer> blocking HTML "load" event

2015.10.07 t

On the importance of simulated latency testing, and bulletproofing your page from the third-party JS load failures

TL;DR

  • use <script src=".." async defer> for all your non-critical script, like analytics, ad networks etc.
  • listen to DOMContentLoaded event instead of load
  • sometimes however you may prefer to wait for load event; then consider loading non-critical external scripts programatically, in a load event listener itself, instead of putting them in HTML, even when using async (hence making load fire faster)
  • use guards in your JS whenever using a global variable from a non-critical script, or provide a stub implementation, to be overridden when the real third-party code gets loaded

Last week we've learnt the hard way (in production) about critical rendering path and its friends in modern web browsers.

An external host providing analytics script was very slow to respond. Unfortunately, the third-party script was blocking the load of all the further resources of the page. Hence it took around 70 seconds to load the login page although our own servers were working fine!

Primer on critical rendering path

It is a common knowledge that you should use <script src=".." async defer> (or set script.async = true before assigning src, when you do it from JS) and/or put your scripts at the very bottom of the page, so that as much as possible of the page gets loaded and rendered to the user, as fast as possible -- no one likes staring at a blank white page; users are impatient and will leave quickly. If you fail to provide the async flag, the script in synchronous; the browser can't proceed with rendering and any JavaScript execution until the sync script is loaded executed (since perhaps the script may want to use document.write and append to the DOM on-the-fly).

If your page is just an HTML page enhanced with some JavaScript, then you're good with just <script async>.

Our analytics script was loaded this way. We would display a splash screen without waiting for the script to load, but the actual app load took much more time. This is because...

DOMContentLoaded vs load

If you're a JavaScript-heavy single-page application, the next important thing is the event you use to boot your app.

DOMContentLoaded is an event fired when the HTML is parsed and rendered and DOM is constructed. It is usually fired pretty fast in the lifetime of the app. On the other hand, load is only fired when all the resources (images, stylesheets etc.) are retrieved from the network and available to the browser. This means a single slow image or script can slow down the load event by an order of magnitude. (DOMContentLoaded is not raised in IE8-, but you can work around it via a bit more complex readystatechange observation).

What might be surprising is that <script async defer> also blocks the raising of load event!

In a web dev slang, you may hear a script with async flag to be called non-blocking, but they're not blocking only the construction of the DOM tree (i.e. DOMContentLoaded event). But they're still blocking the load event.

Unfortunately we were doing just that: using window.onload = ... instead of listening to DOMContentLoaded to boot our app. In normal circumstances, the difference was negligible, but today it his us hard.

Actually, in our case, we were loading an external script via JS, using script.async = true, and that external script was in turn loading several others, also setting the async flag to each of them. However, no amount of async can save you if you're waiting for load event!

Testing the slow resources

If you're on Windows, you should already know Fiddler. If not, you're missing out. Go and install it now. I'll wait.

Fiddler registers itself as a proxy and allows you to sniff on a HTTP(S) traffic, modify it, simulate latencies etc. It's much better than any browser's extension.

The very cool feature is adding artificial latencies to particular HTTP requests. That way you can test what happens when an external resource is slow to load, just as in the described case. See below a screenshot which shows how to set up an autoresponder with a latency (drag'n'drop the request from the requests list to create an autoresponder pane and then assign it a latency).

Fiddler

I've assigned 9999 ms latency to test.js. This makes the load event fire after 10.07 seconds in my case.

Did I already tell you that waiting for load event might be suboptimal?

Does you page work with Adblock on?

You might like or not the adblockers, but your page should work fine regardless if the user is blocking or not the ads and trackers (for your personal page you may not care, but I bet the company you work for would like the page to be usable).

It happened to me once on a major airline's website that I could not proceed with the booking because I was blocking Google Analytics. The page loaded fine without GA, but then after I clicked some button, the JS code wanted to track that event, but since that required using a global JavaScript variabled injected by GA, it threw an exception and the JavaScript code could not complete.

Now, I am a web dev, so I figured this quickly. Your grandma to whom you've installed Adblock will not.

Try it now:

  • Install Adblock Plus on Firefox.
  • Open your website
  • Click Open blockable items (CTRL-SHIFT-V)
  • Add all analytics scripts, ads providers etc. to the blacklist.
  • Refresh the page

Your page should load normally, and should be working normally even without non-critical third-party JS. Otherwise you're doing it wrong, just as we were.

Working around a blocked analytics script

Let's say you load http://example.org/analyticsTracker.js which exposes global trackEvent JS function.

Then in the code you have calls like trackEvent("userClickedSomething").

If analyticsTracker.js is blocked via an adblocker rule, this JS call will obviously fail, and since it is invoked in a click handler, the method will throw an exception and fail to complete, perhaps missing to execute some application-critical code (like submitting a request to the server).

You can just guard all the code with if (trackEvent) trackEvent(...) but it's likely you'll forget a guard here and there and your app will still break.

A more bulletproof solution for protecting yourself is to just define window.trackEvent = function(){} temporarily and wait for it to be overwritten once analyticsTracker.js is loaded.

In case that analyticsTracker.js somehow never gets loaded, or takes long time to do so, you lose some analytics, but at least the users can use the page normally.

Related reads

Once I wrote all of this down, I found a similar article from 2011 which comes to many similar conclusions:

<html>
<head>
<script>
function logElapsed(eventName, eventTime) {
var loadingStartTime = performance.timing.domLoading;
var elapsed = ((eventTime - loadingStartTime) / 1000).toFixed(2);
console.log(eventName + '! took me only ' + elapsed + ' seconds to fire');
}
document.addEventListener('DOMContentLoaded', function(){
logElapsed('DOMContentLoaded', performance.timing.domContentLoadedEventStart);
}, false);
window.onload = function(){
logElapsed('load', performance.timing.domComplete);
}
</script>
</head>
<body>
</body>
<!--
I am loading this script in the bottom, and async, and defer!
Should be non-blocking, no?
-->
<script src="./test.js" async defer></script>
</html>
console.log('hello world from async defer script');
DOMContentLoaded! took me only 0.13 seconds to fire
hello world from async defer script
load! took me only 10.07 seconds to fire
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment