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."
--
What might be some general rules to arise out of this analysis
Fully unobtrusive code (no inline scripts) is a good ideal to go for.
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.
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.
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.