Page & resource loading
Run JavaScript at the right moment in the page lifecycle: DOMContentLoaded, load, async and defer scripts, visibility changes, beforeunload, and resource load errors.
- 01Pick the right momentChoose
DOMContentLoaded,load,visibilitychange,pagehide, or a single file's own event for a real task. - 02Predict when scripts runExplain how normal,
defer,async, and module scripts change the reading of the HTML and the timing ofDOMContentLoaded. - 03Handle leaving the page safelyPause work when the page is hidden, save quickly on
pagehide, and usebeforeunloadonly for unsaved changes.
The page has a timeline
A web page does not appear all at once. The browser reads the HTML from top to bottom and builds the page. Then it downloads images and other files. Your JavaScript must run at the right moment in that timeline.
Code that runs too early or too late causes real bugs:
- Code looks for a button before the browser has read that button. It gets
null, which means "nothing found". - Code measures an image before the image arrives. It gets the wrong size.
- Code keeps asking the server for news while the tab is hidden. It wastes battery and data.
This lesson names the important moments. Each moment is an event: a signal from the browser that your code can listen for. You will meet these moments:
- The HTML is fully read, and the DOM is ready. The DOM is the tree of elements the browser builds from your HTML.
- Every image and other file has finished loading.
- Scripts run, early or late, depending on how you add them.
- The page is hidden, or the user leaves it.
- One single image loads, or fails.
Some demos run a real page inside a small closed-off frame on this page, so you can watch it start up. The hidden-page demo uses this real tab, because "hidden" is about the tab you are reading.
Page lifecycle code runs at the moment that fits its job:
- Add listeners to elements once the HTML is read.
- Measure things once their images have loaded.
- Pause work or save quickly when the page is hidden.
- Watch one file with that file's own events.
Imagine moving into a new house. Once the walls and furniture are in place, you can walk around and start filling the shelves. That is like DOMContentLoaded: the page's structure exists.
Later, every delivery truck has arrived too, with the pictures, the rugs, and the big sofa. That is like load: all the extra files are done.
- In real life: The walls and furniture are in place
- In JavaScript: The HTML is read, and
DOMContentLoadedfires - In real life: Every delivery has arrived too
- In JavaScript:
loadfires after images, iframes, stylesheets, and other files - In real life: You start filling the bookshelves
- In JavaScript: You add listeners to elements at
DOMContentLoaded, or from adeferscript - In real life: You measure the sofa after it arrives
- In JavaScript: You read an image's final size at
load, or at that image's ownload
One rule runs through the whole lesson: use the earliest event that already has what your code needs. An earlier event makes the page feel faster. Wait for a later event only when your code needs something that arrives later.
DOMContentLoaded, load, and readyState
INTERACTIVEdocument.readyState is a word that says how far the page has got. It has three values, always in this order:
loading: the browser is still reading the HTML.interactive: the HTML is fully read.DOMContentLoadedfires soon after, once anydeferscripts have run. You will meet those in the next section.complete: images, iframes, and other files are done. Theloadevent fires now.
Read each row of this table from left to right: the moment, what is ready, and whether it waits for images.
| The moment | What is ready | Does it wait for images? |
|---|---|---|
loading | The browser is still reading the HTML. | No. |
interactive | The HTML is fully read. defer and module scripts run next. | No. |
DOMContentLoaded | Every element exists, and defer and module scripts have finished. | No. |
complete / load | The page and all its files have loaded. | Yes, for normal images, iframes, and stylesheets. |
const states = ["loading", "interactive", "complete"];
console.log(states.join(" -> "));Line 1 lists the three values in order. Line 2 joins them with arrows, so it prints loading -> interactive -> complete. These are steps the page goes through. You do not need to keep checking them in a loop.
readyState only moves forward. DOMContentLoaded comes with interactive, and load comes with complete.const changes = ["interactive", "complete"];
for (const state of changes) {
console.log("readystatechange: " + state);
}readystatechange is an event that fires each time readyState changes. This loop copies that: it prints one line for each change. So a readystatechange listener can run more than once. If you want one clear starting point, listen for the event made for it, such as DOMContentLoaded.
A safe startup helper has two branches:
- If
readyStateis stillloading, add aDOMContentLoadedlistener. - Otherwise, run the code right away.
The second branch matters. If you add a listener after DOMContentLoaded has already fired, the browser does not fire it again. Your listener would never run.
Step through a safe helper for code that needs the page's elements. Change the readyState setting to see why a late listener needs this check.
script
function onDomReady(callback) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", callback, { once: true }); } else { callback(); }} Now watch a real page start up. Its code runs inside an iframe: a small page shown inside this page. Three things keep it safe and separate:
- Its HTML comes from the
srcdocattribute, not from a server. sandbox="allow-scripts"lets it run scripts but cuts it off from this page.- It sends each event out with
postMessage. This page accepts a message only whenevent.sourceis that exact frame.
const started = performance.now();const log = (label) => parent.postMessage({ type: "timeline", label, ms: Math.round(performance.now() - started), state: document.readyState }, "*");log("script start");document.addEventListener("readystatechange", () => log("readystatechange"));document.addEventListener("DOMContentLoaded", () => log("DOMContentLoaded"));window.addEventListener("load", () => log("window load"));const img = document.querySelector("img");img.addEventListener("load", () => log("image load"));img.addEventListener("error", () => log("image error"));The frame sends each event, with its time, to this page. This page accepts only messages that come from this frame. A data URL can show an image that works and one that fails, but not a slow download.
sandbox="allow-scripts" and srcdoc. It does not run any code you type.The images here are data URLs: the image data is written inside the address itself, so no server is needed. One image is good, and one is broken on purpose. A data URL arrives almost at once, so this demo cannot copy a slow network. It does show three real things:
- the order of the events,
- how
readyStatechanges, - whether the image loaded or failed.
Normal, defer, async, and module scripts
INTERACTIVEA script tag can change how the browser reads the page. It depends on the tag's attributes, the extra words inside the tag such as defer. These rules are about external scripts: scripts loaded from a file with src.
- A normal external script stops the browser from reading the HTML. The browser downloads the script, runs it, and only then reads on.
- A
deferscript downloads while the browser keeps reading. It runs after all the HTML is read, in page order, beforeDOMContentLoaded. - An
asyncscript also downloads while the browser keeps reading. It runs as soon as it arrives, in whatever order the downloads finish.
Builders are putting up a house from a plan. Guests arrive while they work:
- Some guests block the hallway until they finish talking.
- Some guests wait politely for their turn.
- Some guests burst in the moment they arrive.
You choose how each guest behaves, based on what it needs.
- In real life: A guest stands in the hallway and tells a long story
- In JavaScript: A normal script stops the HTML reading while it downloads and runs
- In real life: Guests wait politely and speak in the order on the invitation
- In JavaScript:
deferscripts run after the HTML is read, in page order - In real life: A courier bursts in as soon as the package arrives
- In JavaScript:
asyncscripts run as soon as they download, in any order - In real life: A booked speaker already knows to wait
- In JavaScript: Module scripts (
type="module") wait likedefer, unless markedasync
function chooseScriptMode(needsOrder) {
return needsOrder ? "defer" : "async";
}
console.log(chooseScriptMode(true));
console.log(chooseScriptMode(false));The first call says the script needs order, so it prints defer. The second call says the script works on its own, so it prints async. Choose by what the script needs, not by which word sounds faster.
defer scripts run in the order they appear in the page, then DOMContentLoaded fires. async scripts run as soon as each one downloads, so B can run before A.log("parser before script");<script src="data:text/javascript,..."></script>log("parser after script");document.addEventListener("DOMContentLoaded", () => log("DOMContentLoaded"));Each script is a tiny data URL that runs in the frame. defer keeps page order and runs before DOMContentLoaded. async runs each script when its download finishes. These files arrive almost at once, so async may look ordered here, but that order is not promised.
async and defer change external classic scripts only. They do nothing for inline classic scripts. Module scripts wait like defer by default.A few more details to remember:
deferandasynconly change external classic scripts. A classic script is a normal script, not a module. An inline script, written straight inside the tag, runs right where it appears.- Module scripts (
type="module") wait likedeferby default. You can addasyncto them too. - A stylesheet can delay a script that comes after it. The script might ask for the page's styles, so the browser waits for the stylesheet first.
Script loading decides when a script starts. Once the script runs, the event loop decides the order of its callbacks. If tasks and microtasks feel unclear, review The event loop.
visibilitychange, pagehide, and beforeunload
LIVE PAGEA page does not always get a clean goodbye:
- A phone browser may freeze a page that is in the background.
- When the user presses Back, the browser may bring the page back from the back/forward cache, or bfcache. This cache keeps a whole page in memory, so Back is instant and nothing reloads.
So unload, the event for a page closing, is a bad place for important work. It often does not fire. Using it can also stop the bfcache from working.
visibilitychange says the page was hidden or shown. It does not promise that the page is closing. A hidden page may come back, freeze, or be thrown away. So save a small copy that is safe to save more than once. Do not count on one last callback.
Use each event for its own job:
visibilitychange: the page became hidden or visible. Pause animations, stop asking the server for updates, and send small final messages early.pagehide: the user is leaving the page. Itsevent.persistedistruewhen the page is going into the bfcache.beforeunload: only for the "you have unsaved changes" warning.
When nobody is watching the TV, you pause it. A web page should do the same with work the user cannot see. Save quickly, then let the browser rest.
- In real life: Someone leaves the room
- In JavaScript: The page becomes
hidden - In real life: You turn off the TV and stop the game clock
- In JavaScript: You pause animations, server checks, and costly timers
- In real life: They might come right back
- In JavaScript: The page may go into the bfcache and fire
pageshowwhen it comes back
function workFor(state) {
return state === "hidden" ? "pause and save" : "resume";
}
console.log(workFor("hidden"));
console.log(workFor("visible"));The first call prints pause and save because the page is hidden. The second prints resume because the page is visible again. In a real listener, document.visibilityState gives you the state: "hidden" or "visible".
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { pauseAnimation(); navigator.sendBeacon?.("/analytics", draftSnapshot); } else { resumeAnimation(); }}); window.addEventListener("pagehide", (event) => { saveFast("leaving or entering bfcache", event.persisted);}); function warnIfDirty(event) { event.preventDefault(); event.returnValue = ""; // legacy signal; browsers ignore custom text} function setDirty(isDirty) { if (isDirty) { window.addEventListener("beforeunload", warnIfDirty); } else { window.removeEventListener("beforeunload", warnIfDirty); }}Switch to another tab, then come back. The log adds a line on each visibilitychange. The counter only goes up while the page is visible.
beforeunload listener. This demo never uses unload.Keep the beforeunload code small. It needs two lines:
event.preventDefault()asks the browser to show its warning.event.returnValue = ""asks the same thing in older browsers.
Modern browsers show their own general message and ignore any text you write. Most also show it only if the user has clicked or typed on the page. Add the listener only while there are unsaved changes. Remove it as soon as the work is saved.
function warnIfDirty(event) { event.preventDefault(); event.returnValue = ""; // legacy signal; browsers ignore custom text} function setDirty(isDirty) { if (isDirty) { window.addEventListener("beforeunload", warnIfDirty); } else { window.removeEventListener("beforeunload", warnIfDirty); }}Lines 2 and 3 ask for the warning. Line 8 adds the listener when there are unsaved changes. Line 10 removes it when there are none.
navigator.sendBeacon(url, data) sends a small piece of data to the server, even while the page is going away. It returns true if the browser accepted the data for sending, or false if not. Use it for small messages such as analytics, which are counts of what users do. Do not wait for a reply, and do not send a large draft with it.
function queueAnalytics(bytes) {
return bytes <= 64 * 1024 ? "queued" : "too large";
}
console.log(queueAnalytics(120));
console.log(queueAnalytics(70000));This model prints queued for a small amount of data and too large for a big one. The 64 KB limit here is only for the model. The real limit is up to the browser, so check the true or false result. Do not assume the data was sent.
function describeReturn(persisted) {
return persisted ? "restore cached page" : "start a fresh page";
}
console.log(describeReturn(true));
console.log(describeReturn(false));pageshow fires when a page is shown, including after Back. Its event.persisted is true when the page came from the bfcache. This model copies that: true prints restore cached page, and false, a normal load, prints start a fresh page.
onload and onerror for resources
INTERACTIVEThe whole page has load events. So does each single file. Images, scripts, stylesheets, and iframes each fire their own events:
loadwhen the file arrives and works.errorwhen it fails.
You can set a handler with a property, such as img.onload. addEventListener is usually better: you can add more than one listener and remove them later.
function imageMessage(eventName) {
return eventName === "load" ? "show image" : "show fallback";
}
console.log(imageMessage("error"));
console.log(imageMessage("load"));The first call prints show fallback because the image failed. A fallback is something to show instead, such as a plain placeholder box. The second call prints show image.
In real code, add both listeners before you set src. Otherwise a fast image could finish before your listeners exist.
load when it works and error when it fails. Your code picks what to show for each.const events = ["load", "error"];
for (const event of events) {
console.log(event + " means a resource outcome");
}This loop prints one line for each event. A file that works fires load. A file that fails, or cannot be read, fires error.
function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.addEventListener("load", () => resolve(img)); img.addEventListener("error", () => reject(new Error("Image failed"))); img.src = src; });}Choose an image to load.
Choose an image to load.
load and error events, such as scripts and stylesheets, work the same way.You can wrap these events in a Promise: an object for a result that comes later, which you can await. Turning event code into a Promise is called promisification. If that idea is new, the Promisification lesson explains it.
function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.addEventListener("load", () => resolve(img)); img.addEventListener("error", () => reject(new Error("Image failed"))); img.src = src; });}- Line 3 makes a new image.
- Line 4 resolves the Promise with the image when
loadfires. - Line 5 rejects the Promise with an error when
errorfires. - Line 6 sets
srclast, so loading starts only after both listeners exist.
An error event says the file failed, but not why. Show a useful fallback. Do not guess the cause, and do not show the full address of a file that comes from another site.
Where you will use this
SORT ITThese choices show up in everyday code:
- Start a menu as soon as its elements exist.
- Wait for a chart image before you measure it.
- Pause a dashboard while its tab is hidden.
- Save a small piece of data before the browser freezes the page.
function momentFor(job) {
if (job === "wire buttons") return "DOMContentLoaded";
if (job === "pause dashboard") return "visibilitychange";
return "resource load";
}
console.log(momentFor("wire buttons"));
console.log(momentFor("pause dashboard"));
console.log(momentFor("wait for chart image"));Wiring buttons prints DOMContentLoaded. Pausing a dashboard prints visibilitychange. Any other job, such as waiting for a chart image, reaches line 4 and prints resource load.
The sorter below asks for the moment that fits each task best.
- Add click listeners to buttons that are already in the HTML
- Start a menu script that reads the page's navigation links
- Read the final size of a large top image after it loads
- Wait until an embedded iframe has loaded its page
- Save a draft when the user switches tabs
- Send a small analytics message as the page is hidden or put in the bfcache
- Warn before leaving while a form has unsaved changes
- Remove the leave warning after the draft is saved
Put each task on the moment that fits it best.
function startWhenReady(setup) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setup, { once: true }); } else { setup(); }} startWhenReady(() => { document.querySelector("#save").addEventListener("click", saveDraft);}); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") navigator.sendBeacon("/draft", currentDraft());});- Line 2 checks whether the HTML is still being read.
- Line 9 passes in the setup code, which adds a click listener to the Save button.
- Line 13 listens for the page being hidden or shown. When it is hidden, line 14 sends the draft with
sendBeacon.
Common misconceptions
- “DOMContentLoaded means everything has loaded.” It means every element exists, and
deferand module scripts have finished. Images and iframes may still be loading. - “load is always better because it is safer.” It is only later. Use
loadwhen you need images or other files, not just elements. - “async and defer work on inline scripts.” They only change external classic scripts, the ones with
src. An inline classic script runs right where it appears. - “async scripts run after DOMContentLoaded.” They may run before it or after it. Each one runs when its download finishes.
- “beforeunload lets me write my own warning.” Modern browsers show their own general text, and only in some cases.
- “unload is the right place for cleanup.” It often does not fire, and it can stop the bfcache from working. Use
visibilitychange,pagehide, andsendBeaconfor small final work.
Each row of this table is one event: what to use it for, and what not to use it for.
| The event | Use it to | Do not use it to |
|---|---|---|
DOMContentLoaded | Add listeners once every element exists. | Wait for images or the final layout. |
load | Act once every image and file has loaded. | Run startup code that could start earlier. |
visibilitychange | Pause, resume, or quickly save when the page is hidden. | Stop the user from leaving with a warning. |
pagehide | React when the user leaves, and check for the bfcache. | Run long cleanup that makes the user wait. |
beforeunload | Warn about unsaved changes, and nothing else. | Send analytics, show your own message, or clean up. |
Practice exercises
5 EXERCISESThe model page's readyState is interactive. What does the program print?
const documentModel = { readyState: "interactive", addEventListener() { console.log("listener added"); } };
function onReady(callback) {
if (documentModel.readyState === "loading") {
documentModel.addEventListener("DOMContentLoaded", callback);
} else {
callback();
}
}
onReady(() => console.log("run setup"));console.log("run setup");readyState is not loading, so the helper takes the else branch and calls the callback right away.
Which event name does the program print?
function momentFor(task) {
if (task.includes("image size")) return "load";
if (task.includes("switches tabs")) return "visibilitychange";
return "DOMContentLoaded";
}
console.log(momentFor("save when user switches tabs"));The task includes switches tabs, so the function returns visibilitychange. That event fires when the page becomes hidden or visible.
Which attribute runs external classic scripts in page order, before DOMContentLoaded?
<script defer src="app.js"></script>defer downloads the script while the HTML is read. Then it runs the scripts in page order, before DOMContentLoaded.
Which image event tells you the image is broken?
img.addEventListener("error", showFallback);A broken image fires error. Handle it with an error listener or with onerror.
A teammate sends analytics from window.addEventListener("unload", ...). Which event name should they stop using?
document.addEventListener("visibilitychange", saveIfHidden);
window.addEventListener("pagehide", sendFinalBeacon);Do not use unload for analytics or saving. Use visibilitychange or pagehide, and send short messages with sendBeacon.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does
DOMContentLoadedwait for?Choose an answer to see the explanation.
Question 2 of 8What does this program print?
Read the code, then predictconst documentModel = { readyState: "interactive", addEventListener() { console.log("listener added"); } }; function onReady(callback) { if (documentModel.readyState === "loading") documentModel.addEventListener("DOMContentLoaded", callback); else callback(); } onReady(() => console.log("run setup"));Choose an answer to see the explanation.
Question 3 of 8Which script attribute runs external classic scripts in page order, after the HTML is read and before
DOMContentLoaded?Choose an answer to see the explanation.
Question 4 of 8What is true about inline scripts?
Choose an answer to see the explanation.
Question 5 of 8Which event is best for pausing updates or animation when the user switches tabs?
Choose an answer to see the explanation.
Question 6 of 8How do modern browsers usually show a
beforeunloadwarning?Choose an answer to see the explanation.
Question 7 of 8Which image event fires for a broken data URL?
Choose an answer to see the explanation.
Question 8 of 8Why avoid
unloadfor sending analytics?Choose an answer to see the explanation.
Key takeaways
DOMContentLoadedfires when every element exists.loadalso waits for images and other files.- Check
document.readyStatebefore you add a startup listener, in case the event has already fired. deferruns external classic scripts in page order, beforeDOMContentLoaded.asyncdoes not keep the order.- Pause and save on
visibilitychangeorpagehide. Avoidunload. - Use an image's own
loadanderrorevents to handle that one file.
Page lifecycle work means running each piece of JavaScript at the earliest moment when everything it needs is ready.
Up next: Form properties & elements.