Skip to main content

How to Handle Page Lifecycle Events in JavaScript

Every web page goes through a lifecycle: the browser starts downloading HTML, parses it into a DOM tree, loads external resources like images and stylesheets, and eventually the page is fully ready for the user. When the user navigates away, the page goes through a teardown sequence. At each stage of this lifecycle, the browser fires specific events that let your JavaScript code respond at exactly the right moment.

Understanding these lifecycle events is essential for writing reliable web applications. Running code too early means DOM elements might not exist yet. Running code too late means the user sees an unfinished page. Knowing the difference between DOMContentLoaded and load determines whether your interactive features initialize in milliseconds or seconds. And handling beforeunload correctly can prevent users from accidentally losing unsaved work.

This guide covers every page lifecycle event in order: from the initial HTML parsing (DOMContentLoaded) through full resource loading (load), to page departure (beforeunload and unload). You will also learn about document.readyState and the readystatechange event, which let you check and react to the page's current loading stage at any point.

DOMContentLoaded: HTML Parsed, DOM Ready

The DOMContentLoaded event fires on the document object when the browser has finished parsing the HTML and building the complete DOM tree. At this point, all HTML elements exist as DOM nodes and can be queried and manipulated with JavaScript.

This is the earliest point at which you can safely interact with the DOM.

Basic Usage

document.addEventListener("DOMContentLoaded", () => {
console.log("DOM is ready!");

// Safe to access any element in the HTML
let header = document.getElementById("main-header");
let buttons = document.querySelectorAll(".action-btn");
let form = document.querySelector("form");

console.log(`Found ${buttons.length} buttons`);
});

What Is and Is Not Ready

When DOMContentLoaded fires:

Ready:

  • The complete DOM tree (all HTML elements are nodes you can access)
  • Inline <style> elements have been parsed
  • Scripts that appeared before the event have executed

Not necessarily ready:

  • Images may still be loading (<img> elements exist but may have width/height of 0)
  • External stylesheets may still be loading
  • Iframes may still be loading their content
  • Fonts may still be downloading
  • Other external resources (<video>, <audio>) may still be loading
document.addEventListener("DOMContentLoaded", () => {
let img = document.querySelector("img");

// The element EXISTS in the DOM
console.log(img); // <img src="photo.jpg">

// But it may not be fully loaded yet
console.log(img.naturalWidth); // May be 0 (image still downloading)
console.log(img.complete); // May be false

// DOM operations work perfectly
let container = document.getElementById("app");
container.classList.add("initialized");
});

DOMContentLoaded and Scripts

Regular <script> tags (without async or defer) block HTML parsing. The browser must download and execute the script before it continues parsing the rest of the HTML. This means DOMContentLoaded waits for all synchronous scripts to finish:

<!DOCTYPE html>
<html>
<head>
<title>Page</title>
</head>
<body>
<h1>Hello</h1>

<script>
// This script runs BEFORE DOMContentLoaded
// Parsing pauses here until this script finishes
console.log("Script executed");
</script>

<p>World</p>

<script>
document.addEventListener("DOMContentLoaded", () => {
// This runs AFTER all HTML is parsed and all synchronous scripts have run
console.log("DOMContentLoaded fired");
});
</script>
</body>
</html>

Output order:

Script executed
DOMContentLoaded fired

DOMContentLoaded and Stylesheets

Stylesheets themselves do not block DOMContentLoaded. However, if a <script> tag appears after a <link rel="stylesheet">, the browser must wait for the stylesheet to load before executing the script (because the script might read computed styles). This indirectly delays DOMContentLoaded:

<head>
<!-- This stylesheet loads... -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<p>Content</p>

<!-- This script must wait for styles.css to load first -->
<!-- (because it might read computed styles) -->
<script>
// This script is blocked until styles.css is loaded
// DOMContentLoaded waits for this script
console.log("Script after stylesheet");
</script>
</body>

If there is no <script> after the stylesheet, the stylesheet does not block DOMContentLoaded.

DOMContentLoaded and defer / async Scripts

Scripts with the defer attribute download in parallel with HTML parsing but execute after parsing is complete and before DOMContentLoaded:

<head>
<script defer src="app.js"></script>
</head>
<body>
<h1>Hello</h1>
<script>
document.addEventListener("DOMContentLoaded", () => {
console.log("DOMContentLoaded");
});
</script>
</body>

Execution order:

(HTML parsing completes)
(app.js executes - defer scripts run here)
DOMContentLoaded

Scripts with the async attribute download in parallel and execute as soon as they are downloaded, without waiting for HTML parsing. They do not block DOMContentLoaded (unless they happen to finish downloading before parsing is done):

<head>
<script async src="analytics.js"></script>
</head>
<body>
<!-- async scripts do NOT reliably run before DOMContentLoaded -->
<!-- They might run before or after, depending on download speed -->
</body>

When to Use DOMContentLoaded

Use DOMContentLoaded when your code needs to access DOM elements but does not depend on images, fonts, or other external resources being fully loaded:

document.addEventListener("DOMContentLoaded", () => {
// Initialize UI interactions
setupNavigation();
setupFormValidation();
setupEventDelegation();

// Manipulate the DOM
document.getElementById("year").textContent = new Date().getFullYear();

// Start fetching data
loadUserProfile();
});
tip

If your <script> tag is at the end of <body> (just before </body>), all HTML elements above it are already parsed. In that case, you may not need DOMContentLoaded at all, since your script can access everything above it. However, wrapping code in DOMContentLoaded is a good defensive practice, especially when scripts might be moved or when using bundlers that place scripts in <head>.

What If DOMContentLoaded Already Fired?

If you add a DOMContentLoaded listener after the event has already fired, your callback will never run:

// If this code runs after DOMContentLoaded has already fired:
document.addEventListener("DOMContentLoaded", () => {
console.log("This will never execute!");
});

To handle this case safely, check document.readyState (covered later):

function onDOMReady(callback) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback);
} else {
// DOM is already ready
callback();
}
}

onDOMReady(() => {
console.log("This always works, regardless of timing!");
});

load: All Resources Loaded

The load event fires on the window object when everything on the page has finished loading: the DOM, all images, stylesheets, iframes, fonts, and other external resources. This is the absolute last loading event.

Basic Usage

window.addEventListener("load", () => {
console.log("Everything is fully loaded!");

// Now images have their real dimensions
let img = document.querySelector("img");
console.log(`Image size: ${img.naturalWidth} x ${img.naturalHeight}`);

// All fonts are loaded, text is rendered with final fonts
// All iframes have loaded their content
// All stylesheets are applied
});

DOMContentLoaded vs. load: Timing Difference

The time difference between these two events can be significant, especially on pages with large images or many external resources:

let startTime = performance.now();

document.addEventListener("DOMContentLoaded", () => {
let domTime = performance.now() - startTime;
console.log(`DOM ready: ${domTime.toFixed(0)}ms`);
});

window.addEventListener("load", () => {
let loadTime = performance.now() - startTime;
console.log(`Full load: ${loadTime.toFixed(0)}ms`);
});

// Typical output:
// DOM ready: 150ms
// Full load: 2400ms
// (The load event fires much later because images take time to download)

When to Use load

Use load only when your code genuinely needs all resources to be available. This is less common than DOMContentLoaded:

window.addEventListener("load", () => {
// Calculate layout based on actual image dimensions
let images = document.querySelectorAll(".gallery img");
images.forEach(img => {
let aspectRatio = img.naturalWidth / img.naturalHeight;
img.parentElement.style.aspectRatio = aspectRatio;
});

// Initialize a canvas that depends on loaded images
let canvas = document.getElementById("preview");
let ctx = canvas.getContext("2d");
let heroImage = document.getElementById("hero-img");
ctx.drawImage(heroImage, 0, 0); // Only works if image is fully loaded

// Hide the loading spinner
document.getElementById("loading-spinner").hidden = true;

// Measure performance
let perfEntries = performance.getEntriesByType("navigation");
if (perfEntries.length > 0) {
console.log(`Page load time: ${perfEntries[0].loadEventEnd}ms`);
}
});
info

In most cases, prefer DOMContentLoaded over load. Waiting for load delays your JavaScript initialization unnecessarily if you do not need images or other external resources. Users see a faster, more responsive page when interactive features initialize at DOMContentLoaded.

If you need to know when a specific image loads, use the load event on that individual image element instead of waiting for the entire page:

let img = document.querySelector(".hero-image");
img.addEventListener("load", () => {
console.log("Hero image loaded:", img.naturalWidth, "x", img.naturalHeight);
});

// Handle case where image is already cached
if (img.complete) {
console.log("Image was already loaded (cached)");
}

load on Individual Elements

The load event also fires on individual resource elements when they finish loading:

// Image load
let img = new Image();
img.addEventListener("load", () => {
console.log("Image loaded!");
document.body.append(img);
});
img.addEventListener("error", () => {
console.log("Image failed to load!");
});
img.src = "/photos/landscape.jpg";

// Script load
let script = document.createElement("script");
script.addEventListener("load", () => {
console.log("Script loaded and executed!");
});
script.addEventListener("error", () => {
console.log("Script failed to load!");
});
script.src = "https://cdn.example.com/library.js";
document.head.append(script);

beforeunload: Preventing Accidental Leave

The beforeunload event fires on window when the user is about to leave the page. This includes clicking a link to another page, typing a new URL, closing the tab, closing the browser, or refreshing the page.

Its primary purpose is to warn users about unsaved changes before they navigate away.

Basic Usage

window.addEventListener("beforeunload", (event) => {
// Cancel the event to trigger the browser's confirmation dialog
event.preventDefault();
});

When this event is handled, the browser shows a built-in confirmation dialog asking the user if they really want to leave. The dialog appearance and text are controlled entirely by the browser. You cannot customize the message in modern browsers.

Only Show When There Are Unsaved Changes

You should only trigger the beforeunload dialog when the user actually has unsaved work. Showing it unconditionally is annoying and considered bad UX:

let hasUnsavedChanges = false;

// Track changes
let form = document.getElementById("editor-form");
form.addEventListener("input", () => {
hasUnsavedChanges = true;
});

// Clear the flag when the form is saved
form.addEventListener("submit", () => {
hasUnsavedChanges = false;
});

// Only warn when there are unsaved changes
window.addEventListener("beforeunload", (event) => {
if (hasUnsavedChanges) {
event.preventDefault();
}
// If hasUnsavedChanges is false, the page navigates away normally
});

A More Complete Pattern

class UnsavedChangesGuard {
constructor() {
this.isDirty = false;
this.handleBeforeUnload = this.handleBeforeUnload.bind(this);
window.addEventListener("beforeunload", this.handleBeforeUnload);
}

markDirty() {
this.isDirty = true;
}

markClean() {
this.isDirty = false;
}

handleBeforeUnload(event) {
if (this.isDirty) {
event.preventDefault();
}
}

destroy() {
window.removeEventListener("beforeunload", this.handleBeforeUnload);
}
}

// Usage
let guard = new UnsavedChangesGuard();

// When user edits content
document.getElementById("editor").addEventListener("input", () => {
guard.markDirty();
});

// When content is saved
async function saveContent() {
await fetch("/api/save", { method: "POST", body: getContent() });
guard.markClean();
}

Browser Behavior and Restrictions

Modern browsers have strict restrictions on beforeunload to prevent abuse:

Custom messages are ignored. In the past, you could set event.returnValue to a custom string that would appear in the dialog. Modern browsers ignore custom messages and show their own generic text (like "Changes you made may not be saved"):

window.addEventListener("beforeunload", (event) => {
event.preventDefault();

// ❌ Custom messages are IGNORED in modern browsers
event.returnValue = "You have unsaved changes!"; // Ignored
return "Are you sure?"; // Also ignored
// The browser shows its own generic message instead
});

The dialog requires recent user interaction. Some browsers (notably Chrome) may not show the dialog if the user has not interacted with the page at all (no clicks, no keystrokes). This prevents pages from trapping users who opened a link and immediately want to close it.

caution

Use beforeunload only for genuinely important unsaved data. Never use it to:

  • Keep users on your page (dark pattern)
  • Show advertisements before leaving
  • Track page exits (use navigator.sendBeacon instead)

Browsers are increasingly restricting beforeunload behavior. Some mobile browsers ignore it entirely. Treat it as a best-effort safety net, not a guaranteed gate.

unload: Page Is Being Left

The unload event fires on window when the user is actually leaving the page. At this point, the navigation is committed and cannot be stopped. The page is in the process of being torn down.

Basic Usage

window.addEventListener("unload", () => {
console.log("Page is being unloaded");
});

Extremely Limited Usefulness

The unload event is severely limited in what you can do inside it:

  • You cannot prevent navigation (that is beforeunload's job).
  • You cannot use alert(), confirm(), or prompt() (they are blocked).
  • Most asynchronous operations will be canceled (the page is being destroyed).
  • Regular fetch or XMLHttpRequest calls may be aborted before completing.

Sending Data on Page Exit with navigator.sendBeacon

The one practical use for unload (or beforeunload) is sending analytics or cleanup data to a server. Regular fetch calls may be canceled during page unload, but navigator.sendBeacon() is designed specifically for this purpose. It guarantees the request is queued for delivery even after the page is destroyed:

window.addEventListener("unload", () => {
// sendBeacon sends data reliably during page unload
let data = JSON.stringify({
event: "page_exit",
timeOnPage: Math.round(performance.now() / 1000),
scrollDepth: Math.round(
(window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
)
});

navigator.sendBeacon("/api/analytics", data);
});

navigator.sendBeacon:

  • Sends a POST request asynchronously.
  • Is guaranteed to be queued even during page unload.
  • Does not delay the navigation (it is non-blocking).
  • Accepts a Blob, FormData, URLSearchParams, or string as the body.
// Sending with different data formats
navigator.sendBeacon("/api/log", "simple string data");

navigator.sendBeacon("/api/log", new URLSearchParams({
event: "exit",
page: location.pathname
}));

let blob = new Blob([JSON.stringify({ action: "leave" })], {
type: "application/json"
});
navigator.sendBeacon("/api/log", blob);

pagehide: The Modern Alternative to unload

Modern browsers recommend using pagehide instead of unload. The pagehide event works similarly but is compatible with the back-forward cache (bfcache), which lets browsers instantly restore pages when the user clicks the back button:

window.addEventListener("pagehide", (event) => {
if (event.persisted) {
// Page is being cached in bfcache (user might come back)
console.log("Page cached for potential restore");
} else {
// Page is actually being unloaded
console.log("Page is being destroyed");
}

// sendBeacon works here too
navigator.sendBeacon("/api/analytics", JSON.stringify({
event: "page_hide",
cached: event.persisted
}));
});
warning

Adding an unload event listener can disable the back-forward cache in some browsers, making backward navigation slower. Prefer pagehide over unload and pageshow over checking state on load.

// ❌ May disable bfcache
window.addEventListener("unload", () => { ... });

// ✅ bfcache-friendly
window.addEventListener("pagehide", (event) => { ... });

document.readyState

The document.readyState property tells you the current loading state of the document at any point in time. It is a string with one of three values, representing three stages of the page lifecycle.

The Three States

console.log(document.readyState);
// One of: "loading", "interactive", "complete"
StateMeaningCorresponds To
"loading"The HTML document is still being parsed. The DOM is not yet fully built.Before DOMContentLoaded
"interactive"The HTML has been fully parsed and the DOM tree is complete. External resources (images, stylesheets, iframes) may still be loading.DOMContentLoaded is about to fire (or has just fired)
"complete"The document and all external resources have finished loading.load event is about to fire (or has just fired)

The Lifecycle Timeline

Here is the complete sequence of events and state changes during page load:

[1] document.readyState = "loading"

(HTML parsing in progress...)

[2] document.readyState = "interactive"

[3] DOMContentLoaded event fires

(External resources loading: images, stylesheets, iframes...)

[4] document.readyState = "complete"

[5] window "load" event fires

You can verify this order:

// Place this in a <script> inside <head>
console.log("1. Initial readyState:", document.readyState); // "loading"

document.addEventListener("readystatechange", () => {
console.log("readystatechange →", document.readyState);
});

document.addEventListener("DOMContentLoaded", () => {
console.log("DOMContentLoaded, readyState:", document.readyState);
});

window.addEventListener("load", () => {
console.log("load, readyState:", document.readyState);
});

Output:

1. Initial readyState: loading
readystatechange → interactive
DOMContentLoaded, readyState: interactive
readystatechange → complete
load, readyState: complete

Using readyState for Safe Initialization

The most important use of readyState is handling the case where your code might run after DOMContentLoaded has already fired (for example, a dynamically loaded script):

function onDOMReady(fn) {
if (document.readyState === "loading") {
// DOM not yet ready - wait for it
document.addEventListener("DOMContentLoaded", fn);
} else {
// DOM is already ready ("interactive" or "complete")
fn();
}
}

// This works regardless of when the script loads
onDOMReady(() => {
console.log("Safe to access the DOM!");
initializeApp();
});

A more complete version that also handles waiting for full page load:

function onPageReady(fn) {
if (document.readyState === "complete") {
fn();
} else {
window.addEventListener("load", fn);
}
}

function onDOMReady(fn) {
if (document.readyState !== "loading") {
fn();
} else {
document.addEventListener("DOMContentLoaded", fn);
}
}

Checking readyState at Any Time

You can check readyState at any point to make decisions about what is safe to do:

function initGallery() {
if (document.readyState === "complete") {
// All images are loaded: can read natural dimensions
setupGalleryWithDimensions();
} else if (document.readyState === "interactive" || document.readyState === "complete") {
// DOM is ready: can access elements, but images may not be loaded
setupGalleryBasic();
// Wait for images separately
window.addEventListener("load", () => {
updateGalleryDimensions();
});
} else {
// Still loading HTML: wait for DOM
document.addEventListener("DOMContentLoaded", () => {
setupGalleryBasic();
});
window.addEventListener("load", () => {
updateGalleryDimensions();
});
}
}

readystatechange Event

The readystatechange event fires on document every time document.readyState changes. It fires exactly twice during a normal page load: once when readyState changes from "loading" to "interactive", and once when it changes from "interactive" to "complete".

Basic Usage

document.addEventListener("readystatechange", () => {
console.log(`readyState changed to: ${document.readyState}`);
});

// Output during page load:
// readyState changed to: interactive
// readyState changed to: complete

Using readystatechange as an Alternative to DOMContentLoaded and load

You can use readystatechange to react to both lifecycle stages with a single listener:

document.addEventListener("readystatechange", () => {
switch (document.readyState) {
case "interactive":
// Equivalent to DOMContentLoaded
console.log("DOM is ready");
initializeUI();
break;

case "complete":
// Equivalent to window load
console.log("All resources loaded");
startAnimations();
hideLoadingScreen();
break;
}
});

Precise Event Order

Here is the exact order of all lifecycle events with readystatechange included:

let log = [];

document.addEventListener("readystatechange", () => {
log.push(`readystatechange: ${document.readyState}`);
});

document.addEventListener("DOMContentLoaded", () => {
log.push("DOMContentLoaded");
});

window.addEventListener("load", () => {
log.push("window load");
console.log(log.join("\n"));
});

Output:

readystatechange: interactive
DOMContentLoaded
readystatechange: complete
window load

The precise order is:

  1. readystatechange (to "interactive")
  2. DOMContentLoaded
  3. readystatechange (to "complete")
  4. window load
info

In practice, readystatechange is rarely used directly. Most developers use DOMContentLoaded and window load events, which are clearer in intent. The readystatechange event is primarily useful when building libraries or utilities that need to work correctly regardless of when they are loaded.

Practical Example: Page Lifecycle Monitor

Here is a complete example that visualizes the entire page lifecycle in real time, demonstrating all the events and states covered in this guide:

<!DOCTYPE html>
<html>
<head>
<title>Page Lifecycle Demo</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; }
.event-log { list-style: none; padding: 0; }
.event-log li {
padding: 10px 16px;
margin: 6px 0;
border-radius: 6px;
font-family: monospace;
font-size: 14px;
display: flex;
justify-content: space-between;
}
.event-log .time { color: #888; }
.state-loading { background: #fff3e0; border-left: 4px solid #ff9800; }
.state-interactive { background: #e3f2fd; border-left: 4px solid #2196f3; }
.state-domready { background: #e8f5e9; border-left: 4px solid #4caf50; }
.state-complete { background: #f3e5f5; border-left: 4px solid #9c27b0; }
.state-load { background: #fce4ec; border-left: 4px solid #e91e63; }
.state-info { background: #f5f5f5; border-left: 4px solid #9e9e9e; }

.status-bar {
padding: 12px 20px;
border-radius: 8px;
font-weight: bold;
font-size: 16px;
text-align: center;
margin-bottom: 20px;
transition: background-color 0.3s;
}
.unsaved-demo { margin-top: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }
.unsaved-demo textarea { width: 100%; height: 80px; margin: 10px 0; padding: 8px; font-size: 14px; }
.unsaved-indicator { font-size: 14px; padding: 4px 10px; border-radius: 4px; display: inline-block; }
.unsaved-indicator.clean { background: #e8f5e9; color: #2e7d32; }
.unsaved-indicator.dirty { background: #fce4ec; color: #c62828; }
</style>

<script>
// Start timing from the very beginning
var startTime = performance.now();
var events = [];

function logEvent(name, cssClass) {
var elapsed = (performance.now() - startTime).toFixed(1);
events.push({ name, cssClass, time: elapsed });
}

// Log initial state
logEvent("readyState: " + document.readyState, "state-loading");

// Track readystatechange
document.addEventListener("readystatechange", function() {
var cls = document.readyState === "interactive" ? "state-interactive" : "state-complete";
logEvent("readystatechange → " + document.readyState, cls);
});

// Track DOMContentLoaded
document.addEventListener("DOMContentLoaded", function() {
logEvent("DOMContentLoaded", "state-domready");
renderLog();
});

// Track load
window.addEventListener("load", function() {
logEvent("window load", "state-load");
logEvent("readyState: " + document.readyState, "state-info");
renderLog();
updateStatus("complete");
});
</script>
</head>
<body>
<h1>Page Lifecycle Events</h1>
<div class="status-bar" id="status" style="background: #fff3e0;">
Loading...
</div>
<ul class="event-log" id="event-log"></ul>

<!-- Images to delay the load event -->
<p>Images below delay the <code>load</code> event:</p>
<img src="https://picsum.photos/400/200?random=1" alt="Random image 1" width="400" height="200"
onload="logEvent('Image 1 loaded', 'state-info'); renderLog();">
<img src="https://picsum.photos/400/200?random=2" alt="Random image 2" width="400" height="200"
onload="logEvent('Image 2 loaded', 'state-info'); renderLog();">

<div class="unsaved-demo">
<h3>Unsaved Changes Demo</h3>
<p>Type in the textarea below, then try to close or refresh the tab.</p>
<textarea id="editor" placeholder="Type something..."></textarea>
<div>
<span class="unsaved-indicator clean" id="save-status">No changes</span>
<button id="save-btn" style="margin-left: 10px;">Save</button>
</div>
</div>

<script>
function renderLog() {
var list = document.getElementById("event-log");
if (!list) return;
list.innerHTML = "";
events.forEach(function(e) {
var li = document.createElement("li");
li.className = e.cssClass;
li.innerHTML = '<span>' + e.name + '</span><span class="time">' + e.time + ' ms</span>';
list.appendChild(li);
});
}

function updateStatus(state) {
var status = document.getElementById("status");
if (state === "complete") {
status.style.background = "#e8f5e9";
status.textContent = "Page fully loaded (readyState: complete)";
}
}

// Unsaved changes guard
var isDirty = false;
var editor = document.getElementById("editor");
var saveStatus = document.getElementById("save-status");
var saveBtn = document.getElementById("save-btn");

editor.addEventListener("input", function() {
isDirty = true;
saveStatus.textContent = "Unsaved changes!";
saveStatus.className = "unsaved-indicator dirty";
});

saveBtn.addEventListener("click", function() {
isDirty = false;
saveStatus.textContent = "Saved";
saveStatus.className = "unsaved-indicator clean";
});

window.addEventListener("beforeunload", function(event) {
if (isDirty) {
event.preventDefault();
}
});

// Log beforeunload and pagehide
window.addEventListener("beforeunload", function() {
navigator.sendBeacon("/api/log", JSON.stringify({
event: "beforeunload",
timeOnPage: Math.round(performance.now() / 1000)
}));
});

window.addEventListener("pagehide", function(event) {
console.log("pagehide, persisted:", event.persisted);
});
</script>
</body>
</html>

This example demonstrates:

  • Lifecycle event order: Each event is logged with a timestamp, showing the exact order and timing
  • readystatechange transitions from loading to interactive to complete
  • DOMContentLoaded firing after readyState becomes interactive
  • Image load events firing between DOMContentLoaded and window load
  • window load firing last, after all images are loaded
  • beforeunload with unsaved changes detection
  • navigator.sendBeacon for reliable data sending during page exit

Summary

The page lifecycle consists of a predictable sequence of events that let your code run at exactly the right moment.

Loading Events (in order):

Event/StateFires OnWhenUse For
readyState: "loading"documentInitial state while HTML is being parsedChecking if DOM is ready yet
readyState: "interactive"documentHTML fully parsed, DOM tree complete(Use DOMContentLoaded instead)
DOMContentLoadeddocumentDOM is ready, external resources may still be loadingInitializing UI, attaching events, DOM manipulation
readyState: "complete"documentAll resources loaded(Use load instead)
loadwindowEverything loaded: images, styles, iframes, fontsWorking with image dimensions, hiding loading screens

Unloading Events:

EventFires OnWhenUse For
beforeunloadwindowUser is about to leaveWarning about unsaved changes (call event.preventDefault())
pagehidewindowPage is being hidden or unloadedAnalytics, cleanup (bfcache-friendly alternative to unload)
unloadwindowPage is being destroyedLast-chance data sending with sendBeacon (avoid if possible)

document.readyState:

  • "loading": HTML is being parsed
  • "interactive": DOM is ready (just before DOMContentLoaded)
  • "complete": All resources loaded (just before load)
  • Use it to safely initialize code that might run after events have already fired

Key Guidelines:

  • Use DOMContentLoaded for most initialization (faster than load).
  • Use load only when you need fully loaded resources (images, fonts).
  • Use beforeunload sparingly and only for genuine unsaved changes.
  • Use navigator.sendBeacon() for reliable data sending during page exit.
  • Prefer pagehide over unload to preserve back-forward cache compatibility.
  • Use document.readyState checks to handle late-loading scripts safely.