Prefer defer and DOM content loaded to async

This is part of the Semicolon&Sons Code Diary - consisting of lessons learned on the job. You're in the javascript category.

Last Updated: 2024-12-03

A legacy web application contained the following code.

// Top of page
<script async src="{{ mix('js/app.js') }}"></script>
...

// Bottom of page
<script>
  // This Profile object was defined on the `window` object within the `app.js` code above
  Profile.attachDOMDependentJS();
</script>

All was not well, however. Sporadically, in production, we got an error saying Profile was not found. This did not seem to happen in development.

The issue was that the aysnc attribute within the initial script tag meant that rendering continued regardless, so Profile was sometimes not available by the time the page got to the line that needed the Profile object to be available.

What would happen if I put the Profile-dependent code within a callback to DOMContentLoaded?

document.addEventListener('DOMContentLoaded', () => {
  Profile.attachDOMDependentJS()
  ...
});

Surprisingly, it would still be problematic! Why? Because "By loading the script asynchronously, you are telling the browser that it can load that script independently of the other parts of the page. That means that the page may finish loading and may fire DOMContentLoaded BEFORE your script is loaded and before it registers for the event. If that happens, you will miss the event (it's already happened when you register for it)" - source

What about a callback to the load event instead?

document.addEventListener('load', () => {
  Profile.attachDOMDependentJS()
  ...
});

Technically this would work. But there are downsides: This gets run when all external resources (e.g. scripts/images etc.) are downloaded, styles are applied, image sizes known etc. However this can be very very late in the request cycle, so therefore it is rarely used. It corresponds to document.readyState equal to complete (whereas DOMContentLoaded) corresponds to it being interactive.

Incidentally, another approach here is this:

function runOnStart() {}

if(document.readyState !== 'loading') {
  runOnStart();
} else {
  document.addEventListener('DOMContentLoaded', function () {
    runOnStart()
  });
}

This one will run in both the situation where DOMContentLoaded has fired before this LOC is reached and also after. However, if you depend on runOnStart being loaded from another file, and that file is loaded async, then this won't work.

It's also worth knowing that async scripts don't wait on one another. Never have dependencies between them since they are run depending on their LOAD ORDER (i.e. download order) Small files will probably fetch and faster and therefore will probably be run sooner.

What about script tags with the defer attribute? These, by contrast, run in document order (i.e. depending on what you have first on page and get executed AFTER the document is loaded, RIGHT BEFORE DOMContentLoaded).

<script defer src="{{ mix('js/app.js') }}"></script>
...
<script>
  Profile.attachDOMDependentJS();
</script>

But this would have failed too! Why? Because the inline script tag below would get run before the defer script downloads the code containing Profile. However if I had combined defer with DOMContentLoaded, this version would have worked (since defer scripts will be downloaded before DOMContentLoaded)

Be aware of this UI gotcha: "Please note that if you’re using defer, then the page is visible before the script loads. So the user may read the page, but some graphical components are probably not ready yet. There should be 'loading' indication in proper places, not-working buttons disabled, to clearly show the user what's ready and what's not."

--

Lessons

What might be some general rules to arise out of this analysis

  1. Fully unobtrusive code (no inline scripts) is a good ideal to go for.

  2. If that's not realistic, then, given all this load order complication, the best case scenario is to keep your JavaScript code really light (small download size) and avoid defer and async if possible.

  3. The next best, if you must call JavaScript from HTML, is put the script tag that downloads your JavaScript at the bottom of the page after the HTML. FOLLOWING THIS, call your inline JS code.

  4. Otherwise, your go-to should be defer combined with a listener on DOMContentLoaded

Third party scripts, e.g. from Google Analytics and so on can be async because generally they are designed to work no matter where they might appear in your code.

References