Sam Thorogood

Understanding Load Events on the Web

As a web page loads, it emits two events. These are:

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:

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:

And to deep dive even further, here are the two types of non-async scripts that delay this event:

  1. Classically loaded scripts, i.e., boring <script> tags without async or defer 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.

  2. Scripts marked with defer (or module scripts, as they're implied defer) will delay DOMContentLoaded 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:

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:

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.

Shows Chrome's Network loading tool and the DOMContentLoaded event firing before a page's CSS is available
The page is displayed once CSS arrives—at the indicated point after DOMContentLoaded

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:

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:

And if you want to do setup work as a page loads, consider:

Thanks for reading! Find me on Twitter.