Page: DOMContentLoaded, load, beforeunload, unload

Every HTML page moves through a short lifecycle, from “the browser is still reading the markup” to “the user just closed the tab.” Three moments matter most, and each fires an event you can listen for:

  • DOMContentLoaded — the HTML is parsed and the DOM tree is built. External resources such as <img> pictures and stylesheets may still be downloading.
  • load — everything is in: the HTML plus every external resource (images, stylesheets, and so on).
  • beforeunload / unload — the user is on their way out of the page.

Each moment is a hook for a different kind of work:

  • On DOMContentLoaded, the DOM exists, so you can find nodes, wire up buttons, and boot your interface.
  • On load, external resources have finished, so computed styles are applied and image dimensions are known.
  • On beforeunload, the user is trying to leave. You get a chance to warn them about unsaved changes.
  • On unload, they have essentially left. You can still fire off quick, no-wait work like shipping analytics.
DOMContentLoaded
HTML parsed, DOM built. Images/CSS may still be loading.
load
Everything downloaded: images, styles, fonts.
beforeunload / unload
User leaving. Confirm or send a final beacon.
time →
The three lifecycle checkpoints, from parsing to leaving

Let’s walk through each one.

DOMContentLoaded

This event fires on the document object, not on window. And it has no matching on... property, so you have to attach it with addEventListener:

document.addEventListener("DOMContentLoaded", ready);
// there is no document.onDOMContentLoaded = ...

A quick look at what it can and can’t see:

<script>
  function ready() {
    alert('DOM is ready');

    // the image is not loaded yet (unless it was cached), so its size reads 0x0
    alert(`Image size: ${img.offsetWidth}x${img.offsetHeight}`);
  }

  document.addEventListener("DOMContentLoaded", ready);
</script>

<img id="img" src="https://example.com/media/harbor-photo.jpg?w=800&cache=0">

When ready runs, the parser has already reached the end of the document, so the <img> below the script exists in the DOM — you can reference it. What hasn’t happened yet is the image download. Its pixels aren’t in, so offsetWidth and offsetHeight both report 0.

The event sounds trivial: DOM is built, event fires. But its exact timing depends on a couple of things it decides to wait for.

This is the moment to boot your interface. Inside a DOMContentLoaded handler the elements are guaranteed to exist, so you can look them up and wire them without a single null check. Click the button below — the handler that made it work was attached the instant the DOM was ready:

interactiveWiring up the UI on DOMContentLoaded

DOMContentLoaded and scripts

When the parser hits a <script> tag, it stops building the DOM and runs the script first. That pause is deliberate. A script can rewrite the DOM — it might even call document.write — so the browser can’t declare the DOM “ready” while a script is still pending. DOMContentLoaded waits for such scripts to finish.

parse HTMLhit <script> · PAUSEdownload + run scriptresume parsingDOMContentLoaded
A synchronous <script> pauses parsing until it runs

So DOMContentLoaded reliably lands after every blocking script has run:

<script>
  document.addEventListener("DOMContentLoaded", () => {
    alert("DOM ready!");
  });
</script>

<script src="https://example.com/libs/date-utils/3.2.0/date-utils.js"></script>

<script>
  alert("Library loaded, inline script executed");
</script>

Run this and the order is “Library loaded, inline script executed” first, then “DOM ready!”. The external library has to download and run, then the inline script runs — and only after all scripts are done does DOMContentLoaded fire.

DOMContentLoaded and styles

Stylesheets don’t change the DOM tree, so on its own DOMContentLoaded has no reason to wait for CSS.

There’s a catch, though, when a script follows a stylesheet. That script has to wait for the stylesheet to finish loading before it runs:

<link type="text/css" rel="stylesheet" href="style.css">
<script>
  // this script won't run until style.css has finished loading
  alert(getComputedStyle(document.body).marginTop);
</script>

The reason is that a script might read style-dependent values — coordinates, computed margins, widths — like the getComputedStyle call above. To hand back correct numbers, the browser makes the script wait for pending stylesheets.

And here’s the knock-on effect: because DOMContentLoaded waits for scripts, and that script waits for the stylesheet, DOMContentLoaded ends up waiting for the stylesheet too.

<link css>blocks →<script> waits for CSSblocks →DOMContentLoaded waits too
A stylesheet before a script chains the wait onto DOMContentLoaded

Built-in browser autofill

Firefox, Chrome, and Opera trigger form autofill on DOMContentLoaded.

Say a page has a login form and the browser has saved the credentials. On DOMContentLoaded, and with the user’s approval, it may fill those fields in. Since DOMContentLoaded can be delayed by slow-loading scripts, autofill is delayed with it. You’ve probably seen this: on some sites the login and password don’t populate the instant the fields appear — there’s a beat until the whole page settles. That beat is the wait for DOMContentLoaded.

window.onload

The load event on window fires when the entire page is ready — styles applied, images decoded, other resources in. You can attach it through the onload property or with addEventListener.

Because load waits for images, the size check that failed earlier now works:

<script>
  window.onload = function() { // could also be window.addEventListener('load', (event) => {
    alert('Page loaded');

    // by now the image is fully loaded
    alert(`Image size: ${img.offsetWidth}x${img.offsetHeight}`);
  };
</script>

<img id="img" src="https://example.com/media/harbor-photo.jpg?w=800&cache=0">

window.onunload

When a visitor leaves, the unload event fires on window. You can run quick work here that doesn’t block — closing a related popup window, for example.

The one thing people most want to do on the way out is send analytics. Suppose you’ve been recording how the page was used: clicks, scrolls, which areas got seen. When the user leaves, you’d like that data to reach your server.

The problem is that a normal request might get cut off as the page tears down. The purpose-built answer is navigator.sendBeacon(url, data), defined in the Beacon specification.

sendBeacon ships the data in the background. It does not delay navigation — the browser moves on to the next page while still delivering the beacon.

let analyticsData = { /* object with gathered data */ };

window.addEventListener("unload", function() {
  navigator.sendBeacon("/analytics", JSON.stringify(analyticsData));
});
unload firespage navigates away immediately
meanwhile ↓
POST /analytics keeps running in background(response is discarded)
sendBeacon detaches the request from the page's lifetime

A few things to know about it:

  • The request goes out as a POST.
  • You can send more than a string — forms and other body types work too, as covered in Fetch — but a stringified object is the usual choice.
  • The payload is capped at 64 kB.

By the time the beacon completes, the browser has likely left the document, so there’s no way to read the server’s response. For analytics that’s fine, since the response is usually empty anyway.

There’s also a keepalive flag that gives regular Fetch requests the same “outlive the page” behavior for more general cases. See Fetch API for the details.

What you can’t do here is stop the user from leaving. For that you need a different event.

window.onbeforeunload

If a visitor starts navigating away or tries to close the window, a beforeunload handler can ask the browser to confirm the exit.

Cancel the event, and the browser may pop up a “leave this page?” prompt. Try it — run this, then reload:

window.onbeforeunload = function() {
  return false;
};

For historical reasons, returning a non-empty string also counts as canceling. Browsers once displayed that string in the dialog, but the current specification says they shouldn’t anymore:

window.onbeforeunload = function() {
  return "There are unsaved changes. Leave now?";
};

The behavior changed because some sites abused it, using the message to show misleading or nagging text. Older browsers might still surface your string, but otherwise you get a generic, non-customizable prompt.

You can’t reproduce a real “leave this page?” dialog inside this course page, but the logic behind it is easy to model: track whether there are unsaved edits, and guard the exit only when there are. Type in the note, then try to leave — the guard fires only while the note is dirty:

interactiveAn unsaved-changes guard, the shape of beforeunload

readyState

What if you attach a DOMContentLoaded handler after the document has already loaded? It never fires — the moment it was waiting for has passed.

Sometimes you can’t be sure whether the DOM is ready yet, especially in a script that might be loaded early or late (say, a widget dropped onto arbitrary pages). You want your setup to run when the DOM is ready — whether that’s right now or a moment from now.

document.readyState tells you where the document stands. It has three values:

  • "loading" — the document is still being read.
  • "interactive" — the document has been fully parsed.
  • "complete" — the document is parsed and all resources like images have loaded.
“loading”“interactive”≈ DOMContentLoaded →“complete”≈ window.onload
readyState progresses through three values in order

So you can check the state and either register a handler or run immediately:

function work() { /*...*/ }

if (document.readyState == 'loading') {
  // still loading — wait for the event
  document.addEventListener('DOMContentLoaded', work);
} else {
  // DOM is already ready
  work();
}

There’s also a readystatechange event that fires whenever the value changes, so you can log every transition:

// current state
console.log(document.readyState);

// log each change
document.addEventListener('readystatechange', () => console.log(document.readyState));

readystatechange is an older way to track loading progress. It still works, but with DOMContentLoaded and load available it’s rarely the tool people reach for.

To see how everything fits together, here’s a document with an <iframe>, an <img>, and handlers that log each event:

<script>
  log('initial readyState:' + document.readyState);

  document.addEventListener('readystatechange', () => log('readyState:' + document.readyState));
  document.addEventListener('DOMContentLoaded', () => log('DOMContentLoaded'));

  window.onload = () => log('window onload');
</script>

<iframe src="iframe.html" onload="log('iframe onload')"></iframe>

<img src="https://example.com/media/harbor-photo.jpg" id="img">
<script>
  img.onload = () => log('img onload');
</script>

A typical run logs this order:

  1. [1] initial readyState:loading
  2. [2] readyState:interactive
  3. [2] DOMContentLoaded
  4. [3] iframe onload
  5. [4] img onload
  6. [4] readyState:complete
  7. [4] window onload

The bracketed number is the rough time slot. Lines sharing a number happen at about the same moment (give or take a few milliseconds).

[1]readyState: loading
[2]readyState: interactiveDOMContentLoaded
[3]iframe onload
[4]img onloadreadyState: completewindow onload
Event order for a page with an iframe and an image

Two things stand out:

  • readyState flips to interactive right before DOMContentLoaded. Practically speaking, they mark the same instant.
  • readyState flips to complete once every resource — the iframe and the img — is loaded. That lands at roughly the same time as img.onload (the image being the last resource) and window.onload. Reaching complete means the same thing as window.onload, with one nuance: window.onload always runs after every other load handler on the page.

Here’s that same idea running live. The page below holds one image (an inline SVG, so it loads without the network) and logs every lifecycle event as it happens. Watch readyState climb from interactive to complete, with DOMContentLoaded and window load landing right beside each transition:

interactiveWatching the lifecycle events fire in order

Summary

The page-load events, in order of the lifecycle:

  • DOMContentLoaded fires on document when the DOM is ready. This is where you attach behavior to elements.
    • Synchronous scripts — <script>...</script> and <script src="..."> — block it; the browser waits for them to run.
    • Images and other resources may still be downloading at this point.
  • load fires on window once the page and all its resources are in. You’ll rarely need it, since waiting that long is usually unnecessary.
  • beforeunload fires on window when the user tries to leave. Cancel it and the browser asks whether they really mean to go — handy for unsaved changes.
  • unload fires on window as the user finally leaves. You can only do quick, non-blocking work here, which is why it sees little use. Send a final request with navigator.sendBeacon.
  • document.readyState reports the current loading state, and readystatechange fires on each transition:
    • loading — the document is loading.
    • interactive — the document is parsed; lands at about the same time as DOMContentLoaded, just before it.
    • complete — document and resources are loaded; lands at about the same time as window.onload, just before it.