Understanding Load Events on the Web
As a web page loads, it emits two events. These are:
load
, firing when all initial resources are ready: including scripts, images and CSSDOMContentLoaded
, firing when the HTML payload arrives and all non-async scripts have run
However, I argue that neither of these events is very useful. Let's learn why, starting with an interactive demo!
The Demo
Below, you can control the load of a simple HTML page with a number of sliders. While the HTML itself will always load quickly, try changing the time it takes to load the page's CSS, JS or an image.
With the demo's default options, see that DOMContentLoaded
arrives almost immediately—in fact, before anything is even rendered; and load
arrives very late—way after the page is already usable.
Read on to find out why this is the case. 📝
Load Event
Nearly every JavaScript developer has written code like this:
window.addEventListener('load', (event) => {
// do something, the page is ready \o/
});
This event fires after every initally loaded script, image, or CSS file is ready.
Not everything is included here: for example, network requests made via fetch()
or web font files don't contribute to this event.
Additional resources added to the load queue before the event fires will continue to delay it.
For example, if your JS bundle loads your analytics script or CSS it needs later, the load
event will move back after those additional resources load too.
As a visual signal, your browser's loading spinner will spin until load
fires (and I am a big fan of loading spinners).
But as a programatic signal, the event is pretty vague, and perhaps not always what you want:
- pages can often be interactive long before
load
fires—e.g., a page loads quickly and can be interacted with, but waits on a huge hero image taking many seconds - you can't use the
load
event to delay or 'hide' the load of large dependencies—it won't improve your Lighthouse scores
You can still push resource loading until after load
fires, but be careful: if you do further loads in the event handler itself, then the loading spinner can keep spinning anyway.
So be sure to delay their load with e.g., a zero setTimeout
or requestAnimationFrame
.
Lazy Loading
It's worth noting that lazily-loaded images don't contribute to the load
event, even if they're in the initial viewport.
Rather than writing code to delay the load of large images that are part of your body content, consider just marking them with loading="lazy"
.
DOM Content
The other event, DOMContentLoaded
, is more complex.
Let's start with what it represents:
- the HTML payload has arrived
- non-async scripts have arrived and executed
And to deep dive even further, here are the two types of non-async scripts that delay this event:
-
Classically loaded scripts, i.e., boring
<script>
tags withoutasync
ordefer
load and run as soon as your browser sees them, effectively blocking your page load.Your page's HTML will continue to download while this happens, but it's only an optimization, as your browser must stop and invoke this synchronous code.
-
Scripts marked with
defer
(or module scripts, as they're implieddefer
) will delayDOMContentLoaded
until they arrive and execute.Importantly, as your HTML and CSS might arrive before this happens, your page might actually be displayed to the user before these scripts run and
DOMContentLoaded
finally fires. (To try this out, go back to the demo and toggle "JS async" to off.)
Confused? Here's a guide:
<!-- is fetched and run immediately -->
<script src="classic.js"></script>
<!-- blocks DOMContentLoaded, but page might display early -->
<!-- & defer means that the DOM will be ready before run -->
<script src="defer.js" defer></script>
<script type="module" src="m.js"></script>
<!-- script runs anytime, before OR after load -->
<script src="async.js" async></script>
Use-Cases
So the DOMContentLoaded
event shows up at a bunch of different times depending on your loading strategy.
In practice, there's really one reason it's worth using:
- you want to run a script as soon as possible (inline, or
async
) - and it needs to do setup work which then has effect on the DOM.
For example, here's something an external async
script might do:
// kick off some work _immediately_
const dataPromise = window.fetch('/data.json').then((r) => r.json());
// you'll always need this 'dance' to check when the script ran
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', run);
} else {
run(); // the page "won", just run code immediately
}
// now work on the DOM with impunity
async function run() {
const result = await dataPromise;
document.body.append(`Got results: ${result.length}`);
}
But this ends up being a very niche use-case. Some alternatives:
- if you just want to execute script early, just run script and don't use the event at all
- if you want to preload assets, do it declaratively in HTML (or using HTTP headers)
- if your goal is just to manpiulate the DOM, mark your script as
defer
instead.
Third-Party Libraries
One last reason this event is used, albeit one that doesn't really have effect on your own code, is that third-party libraries have no idea when they themselves are going to be loaded.
If I include a library which changes the DOM, that library doesn't know whether you're including it via async
or not, so it may aggressively listen to DOMContentLoaded
in order to ensure it can get its work done.
This shouldn't change the way you build your site's own code, and is an implementation detail of these kinds of libraries (and, due to the above reasons, should go away over time anyway).
Other Loading States
Your page actually goes through other states during its load.
As I mentioned earlier, load
is not the point at which your page becomes available and interactive—so what else is available to us?
CSS
The most interesting and significant event most pages go through is when their CSS becomes available. At this point, your HTML will actually render for users, and potentially become interactive—e.g., allowing the user to scroll or click on links.
There's a rare corner-case here, too: if the CSS arrives far before the HTML content itself finishes arriving, your browser may choose to render partial content—i.e., only what it's recieved so far. But this is rare in practice, even on slow connections.
So if you wanted to listen for the CSS arriving—and you're loading an external stylesheet, not inlining your CSS on the page—you could add a load
listener just to the <link rel="stylesheet" />
itself.
However, by the time your script that adds that event runs, the styles might already be available—this is a hard problem.
No other resource can stop your page from being rendered in this way: not scripts¹, images, web font files, iframes, et al. To be fair, waiting for your CSS is usually pretty desirable, even though I am a huge fan of semantic HTML on its own.
If your CSS is slow to load, inlinling its critical parts may help. And if you're building a SPA, consider inlining all your CSS—there's no overhead since no other page needs the same CSS. 🤷
¹ classically loaded scripts operate synchronously, stopping the browser parsing your page while they're fetched and executed—but only slow CSS can introduce an asynchronous delay to rendering (and other JS can still run!)
Web Font Files
While a custom font is declared inside CSS, its actual source file (e.g., a woff
file) loads separately.
And unlike CSS itself, custom font files can take time to arrive when users first visit your site and won't block rendering the page.
To deal with this loading conundrum, the simplest knob you have is the the font-display
CSS property.
It lets you:
- display no text until the font arrives (the default), or
- display text in a fallback font (using
font-display: swap
)
This gives you an element of control, and is supported on popular font CDNs like Google Fonts. Great!
Interactive
This is a little more fuzzy, but Lighthouse—which measures your page's overall performance—describes Time to Interactive as the time at which the page is displaying useful content, and the page responds to user interaction within 50 milliseconds. Broadly, this describes when a site is loaded and there's not huge amount of work still occuring.
Importantly, you don't have direct control over this 50 millisecond window—it's not as if you're intentionally running a scipt that blocks the main thread for 50ms. But heavy work required to run your page—like parsing incoming CSS, decoding images, etc—can effect your performance here.
If you'd like to learn more about these 'fuzzy' metrics, check out this post on how fast your page should load by the Firebase team. 🔥
An Alternative
The web standard of Custom Elements introduces an alternative to traditional page load.
It basically allows an author to define a named new element type, like <foo-bar>
, and register code that instantiates that element.
From a loading point of view, this completely skips events: I simply load JS as fast as I can, which tells my browser what to do when it sees a <foo-bar>
.
Simultaneously, if my browser sees a <foo-bar>
before it knows what to do with it, it's simply ignored until that code is available.
No events required!
(It shouldn't be surprising that a Google employee is a fan of Custom Elements. But they're an amazing primitive that can be taken up by any number of easier high-level frameworks.)
Key Takeaways
What are the takeaways from this post?
Most importantly, it's important to know that load
and DOMContentLoaded
aren't that useful and I hope that I've improved your understanding of these events (I know researching this post has helped me enormously).
Here's some key points to remember:
- your page will be visible once your CSS loads
- it can also be visible and interactive for a time before either
DOMContentLoaded
orload
fire - the most important implication of
load
is that the browser's loading indicator keeps spinning until it fires.
And if you want to do setup work as a page loads, consider:
- preloading resources and assets declaratively
- marking your script as
defer
(or use module scripts, which are implicitly defer) - not listening to either of
load
orDOMContentLoaded
, and even consider wrapping up your loading behavior inside Custom Elements.
Thanks for reading! Find me on Twitter.