Why Loading Scripts Is Harder Than It Should Be

Loading JavaScript in the browser seems trivial: add a <script> tag and you're done. But the realities of browser behavior, legacy quirks, and performance concerns make it deceptively complex. Understanding how scripts download and execute is essential for choosing the fastest, least disruptive loading strategy.

The WHATWG spec lays out the rules, but reading it is no small feat. Essentially, the way a script downloads and runs depends on when it's added to the document and which attributes it carries.

Starting Simple: Plain Script Tags

Your first attempt at including scripts probably looked like this:

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

This is the simplest case: the browser downloads both files in parallel and executes them in order as soon as each is ready. Script "2.js" waits for "1.js" to finish, and "1.js" waits for any earlier scripts or stylesheets. The problem? While all of this happens, rendering of the page is blocked. This behavior dates back to an era when document.write was common, forcing the browser to pause parsing to avoid missing content.

Modern browsers mitigate this by scanning ahead for external resources while rendering is paused, but the page still doesn't paint. This is why putting scripts at the end of the <body> became standard advice—it minimizes the amount of content blocked. Still, the browser only discovers those scripts after downloading the entire HTML document, which delays everything else you might want to load.

The Defer Attribute: A Partial Fix

Back in Internet Explorer 4, Microsoft introduced defer to solve this. The attribute signals that the script won't inject content into the parser via document.write, allowing the browser to download the file without blocking rendering. Deferred scripts execute just before DOMContentLoaded, in document order—provided the document has finished parsing.

As useful as defer was, it came with its own quirks. Browsers historically disagreed on how it should behave, especially for dynamically added scripts or those without a src attribute. The WHATWG eventually clarified: defer only applies to scripts with a src that are part of the original document, not those added later.

IE9 and earlier had their own interpretation. In those browsers, certain DOM operations could pause the current script and execute pending deferred scripts out of order. Even in correct implementations, deferred scripts wait for the entire document to parse before running—not ideal if you want to start bootstrapping your app earlier.

The Async Attribute: Speed Without Order

HTML5 introduced async as another option:

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

Like defer, async assumes you won't use document.write, but it doesn't force you to wait for parsing to finish. Scripts download in parallel and execute the moment they're ready, which means execution order is not guaranteed. If "2.js" depends on "1.js" (say, a utility library loaded from a CDN), you risk errors. That's fine for independent scripts—tracking snippets, for example—but it's dangerous for anything with dependencies.

What About Script Loaders?

Given these limitations, developers turned to JavaScript libraries to manage loading. Some, like RequireJS, required wrapping your code in callbacks to enforce ordering. Others used XHR to fetch scripts in parallel and then eval() them in order—but that breaks for cross-origin scripts unless CORS headers are in place. More elaborate hacks, like those used by early versions of LabJS, relied on tricking the browser into downloading a script without executing it by using an incorrect MIME type, then re-adding the script with the correct type once everything was ready. That approach depended on unspecified behavior and broke when HTML5 declared that browsers shouldn't download scripts with unrecognized types.

Script loaders also have a built-in performance problem: you must download and parse the loader itself before it can start fetching anything else. Adding that extra round-trip to your critical path means you've already lost the performance battle.

The DOM Answer: Async by Default, Order When Needed

The solution is actually buried in the HTML5 spec, far down in the scripting section. The rule: dynamically created scripts are async by default. They don't block rendering, and they execute as soon as they download—which means they can run out of order. But you can override that:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

Setting async = false explicitly places the script into the execution queue used by standard (non-async) script tags, preserving order. But because the script is created dynamically, it runs outside document parsing, so no rendering is blocked.

Using this pattern, you can keep script downloads queueing from the moment they're parsed, without waiting for the full HTML download, and still get ordered execution. "2.js" can download before "1.js", but it won't run until "1.js" has executed. This combination isn't achievable with plain HTML alone.

This technique works in every browser that supports the async attribute, with one or two notable exceptions. Older Firefox and Opera didn't support async but conveniently executed dynamically added scripts in document order anyway, making them fine in practice.

The Preload Scanner Problem

Dynamically created scripts have one significant drawback: the browser has to parse and execute your inline script before it learns about the scripts to download. This is invisible to the preload scanner—the browser component that looks ahead to discover resources you're likely to need while the parser is busy. A script create via document.createElement can never be preloaded, so your resources are found later than they should be.

You can mitigate this by using link[rel=subresource] in the head:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

This tells the browser the page will need both 1.js and 2.js. Unlike prefetch, subresource is for resources the current page requires, but the semantics for when these requests start are still being worked out. The main drawbacks: it's only supported in Chrome, and you must declare scripts twice—once in the link tags and once in your script loader.

The Missing Piece: Dependent Async Scripts

There is one pattern that current HTML can't handle declaratively: downloading a set of scripts asynchronously, then executing them in order only after a shared dependency is ready. Imagine a page with several enhancement scripts, each requiring utility functions from dependencies.js. You'd want to download all of them in parallel, then run each enhancement as soon as it's ready, but only after the dependency has executed.

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

Neither async nor defer gives you this. Setting async = false on all of them would force execution order, meaning enhancement-2.js waits for enhancement-1.js, even though they're independent. The only way to make this work is to modify the enhancement scripts to coordinate loading themselves, or use markup and manual dependency tracking.

There's literally one browser that handles this natively without hacks: Internet Explorer. IE loads scripts when the src is set, even before the element is added to the document. It also provides a readystatechange event and a readystate property that report loading progress:

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

This separate control over loading and execution means you can build dependency graphs by choosing when an element enters the document. IE has supported this model since version 6. The downside remains the same as async = false: your scripts are invisible to the preload scanner effect.

The Best Practical Approach

If you need non-blocking loading with broad support and no redundancy, there's still no better option than this:

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

That's it—ordinary scripts at the end of the body. It's not elegant, but it blocks the least amount of content and works everywhere. The real hope lies in JavaScript modules, which may eventually offer a declarative way to load scripts asynchronously and control execution order. Until then, careful engineering of your loading strategy is still required.

Mixing the tricks for maximum speed

If raw performance is the goal and complexity is acceptable, the techniques above can be combined into an aggressive loading strategy.

First, declare the script as a subresource so preloaders discover it early:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

Then, inline in the document head, load the scripts via JavaScript with async=false, falling back to IE's readystate-based loading, and finally to defer:

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

After minification, this approach adds 362 bytes plus your script URLs:

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

Whether that overhead pays off depends on your setup. If you're already conditionally loading scripts with JavaScript—as the BBC does—then triggering earlier downloads is a free win. Otherwise, the simple end-of-body script tag remains the pragmatic choice.

At-a-glance loading reference

Plain script elements

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

Spec says: Download together, execute in order after any pending CSS blocks rendering until complete.

Browsers say: Yes sir!

Defer

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Spec says: Download together, execute in order just before DOMContentLoaded. Ignore "defer" on scripts without src.

  • IE < 10: May execute 2.js partway through the execution of 1.js.
  • Browsers lacking HTML5 parser support: Load scripts as if defer isn't there.
  • Other browsers: Ok, though some may not ignore defer on script elements without src.

Async

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

Spec says: Download together, execute in whatever order they finish downloading.

  • Browsers without async support: Load scripts as if the attribute isn't there.
  • Other browsers: Yes, that works.

Script-created elements with async=false

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

Spec says: Download together, execute in insertion order once all are ready.

  • Firefox < 3.6, Opera: Don't know async, but execute dynamically added scripts in insertion order by coincidence.
  • Safari 5.0: Understands async but not setting it to false via JavaScript—executes scripts as they arrive, in whatever order.
  • IE < 10: No native async support, but offers the onreadystatechange workaround.
  • Other browsers without async: Execute scripts as they land, in arbitrary order.
  • Modern browsers: Follow the spec correctly.