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 resource event for a real task.
- 02Predict script timingExplain how normal, defer, async, and module scripts affect parsing and DOMContentLoaded.
- 03Handle page exits safelyPause work when hidden, save quickly on pagehide, and use beforeunload only for unsaved changes.
The page has a timeline
Browser JavaScript is not only about what to run. A big part of professional front-end work is choosing when to run it. If code searches for a button before the browser has parsed that button, the search returns null. If code measures an image before the image loads, the size can be wrong. If code keeps polling while the tab is hidden, it wastes battery and network.
This lesson gives names to the important moments: the DOM becomes ready, resources finish, scripts join the queue, the page is hidden, and individual images either load or fail. We will use real sandboxed frames for startup timing so React does not manage the nodes we are poking, and a live page demo for visibility because that signal belongs to this actual tab.
Page lifecycle code listens for the moment that matches the job: parsed DOM for wiring elements, loaded resources for measurements, visibility changes for pausing or quick saves, and resource events for individual files.
Imagine moving into a house. Once the walls, rooms, and furniture are in place, you can walk around and start arranging things. That is like DOMContentLoaded: the document structure exists. Later, every delivery truck has arrived too: pictures, rugs, and the big sofa. That is like load: external resources are done.
- In real life: Walls and furniture are in place
- In JavaScript: The HTML is parsed and
DOMContentLoadedcan run - In real life: Every delivery has arrived too
- In JavaScript:
loadhas fired after images, iframes, stylesheets, and other resources - In real life: Start arranging bookshelves
- In JavaScript: Attach listeners to existing DOM at DOMContentLoaded or from a deferred script
- In real life: Measure the sofa after it arrives
- In JavaScript: Read final image/resource-dependent sizes at
loador the resource’s ownload
Where the analogy stops: A house has one moving day. A page can become hidden, enter the back/forward cache, return later, and load individual resources at different times.
If you want one rule to carry through the whole lesson, use this: choose the earliest event that has the facts you need. Earlier usually feels faster; later is only better when the job truly depends on later resources.
DOMContentLoaded, load, and readyState
INTERACTIVEdocument.readyState moves through three values. It starts as loading while the parser is reading HTML. It becomes interactive when parsing is done, just before DOMContentLoaded. It becomes complete when the page’s load event is firing, after resources such as images and iframes are finished.
| Moment | What is ready | Does it wait for images? |
|---|---|---|
loading | The parser is still building the document | No |
interactive | HTML is parsed; deferred and module scripts run before DOMContentLoaded | No |
DOMContentLoaded | The DOM is ready and deferred/module scripts finished | No |
complete / load | The document and subresources are loaded | Yes, for ordinary page resources |
A common safe pattern is: if the document is still loading, add a DOMContentLoaded listener; otherwise run immediately. That second branch matters because adding a listener after the event already fired will not replay it for you.
Step through a safe helper for code that needs the parsed DOM. Change the readyState setting and notice why late listeners need a guard.
script
function onDomReady(callback) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", callback, { once: true }); } else { callback(); }} Now watch a real document go through its startup events. The code runs inside a sandboxed srcdoc iframe with sandbox="allow-scripts". It posts messages out, and the lesson accepts only messages whose event.source is 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 posts timestamped events to the parent page. The parent accepts only messages whose source is this iframe. With a data URL we can prove success and error, but not a realistic slow network download without a server.
In this server-free demo a data URL image can succeed and a deliberately broken data URL can fail. A generated data URL does not simulate a real slow network very well, so the experiment is honest about what it can prove: event order, readyState changes, and resource success/error.
Normal, defer, async, and module scripts
INTERACTIVEClassic script tags affect parsing differently depending on their attributes. A normal external script is like a guest who stops the builders until they finish talking. A defer script waits politely until the house is built, then speaks in document order before DOMContentLoaded. An async script interrupts whenever it arrives, in whatever order downloads finish.
The HTML parser is trying to build the page. Some script guests block the doorway, some wait their turn, and some interrupt as soon as they arrive. Your job is to invite each script in the mode that matches what it depends on.
- In real life: A guest blocks the hallway to tell a long story
- In JavaScript: A normal script blocks parsing while it downloads and runs
- In real life: Guests wait politely and speak in the invitation order
- In JavaScript:
deferscripts run after parsing, in document order - In real life: A courier bursts in whenever the package arrives
- In JavaScript:
asyncscripts run as soon as ready, order not guaranteed - In real life: A scheduled speaker already knows to wait
- In JavaScript: Module scripts are deferred by default unless marked
async
Where the analogy stops: Browsers also optimize fetching and stylesheets can delay scripts that come after them. The analogy explains ordering, not every network optimization.
log("parser before script");<script src="data:text/javascript,..."></script>log("parser after script");document.addEventListener("DOMContentLoaded", () => log("DOMContentLoaded"));External data: scripts run in the sandboxed frame. Defer preserves order before DOMContentLoaded; async runs whenever downloads finish, so quick data URLs may look stable but the guarantee is intentionally weaker.
Important details: defer and async apply to external classic scripts with src; they do not change inline classic scripts. Module scripts behave like defer by default, and can be made async. Also remember that a stylesheet can delay a script that comes after it, because the script might ask for computed styles.
Script loading is separate from the event loop, but they meet once a script actually runs. If the task/microtask vocabulary feels fuzzy, review The event loop.
visibilitychange, pagehide, and beforeunload
LIVE PAGEA page does not always get a clean goodbye. Mobile browsers may freeze it. The user may put it into the back/forward cache and return without a full reload. That is why unload is a bad place for important work: it is unreliable and can block the back/forward cache.
Use visibilitychange when the page becomes hidden or visible. It is like someone leaving the room and turning off the TV: pause animations, stop polling, and send tiny final messages early. Use pagehide for page navigation signals, especially with event.persisted to notice bfcache. Use beforeunload only for the unsaved-changes prompt.
When nobody is watching the TV, you pause it. Web pages should do the same with work the user cannot see. Save quickly, then get out of the browser’s way.
- In real life: Someone leaves the room
- In JavaScript: The page becomes
hidden - In real life: Turn off the TV and stop the game clock
- In JavaScript: Pause animation, polling, and expensive timers
- In real life: They might come right back
- In JavaScript: The page may enter bfcache and later fire
pageshow
Where the analogy stops: A hidden page may still run briefly, but browsers can throttle, freeze, or discard background pages. Keep hidden-work small and resilient.
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 tabs or minimize the browser. The log updates on visibilitychange, and the animation counter only advances while visible.
The beforeunload pattern is deliberately narrow: event.preventDefault() plus the legacy event.returnValue = "". Modern browsers show their own generic message, ignore custom text, and in most cases require the user to have interacted with the page first. Register it only while changes are actually unsaved, and remove it as soon as the page is clean.
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); }}onload and onerror for resources
INTERACTIVEThe document has loading events, and so do many resource elements. Images, scripts, stylesheets, iframes, and other resources can fire load when they succeed and error when they fail. You can attach handlers with properties such as img.onload, but addEventListener is usually easier to compose and remove.
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 a resource to load.
Choose a resource to load.
Wrapping resource loading in a Promise is a practical form of promisification: translate callback-style events into a value you can await. If that idea is new, the Promisification lesson explains the general pattern.
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; });}Where you will use this
SORT ITLifecycle choices show up in ordinary product code: start menus as soon as the DOM exists, wait for a chart image before measuring, pause a dashboard when hidden, and save small data before the browser freezes the page. The sorter below asks for the earliest useful moment.
- Attach click listeners to buttons that already exist in the HTML
- Start a menu script that reads the parsed navigation
- Read the final size of a hero image after it loads
- Wait until an embedded iframe has loaded its document
- Save a draft when the user switches tabs
- Send a small analytics beacon 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 lifecycle moment that best fits it.
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());});Common misconceptions
- “DOMContentLoaded means everything loaded.” It means the parsed DOM is ready and deferred/module scripts finished. Images and iframes can still be loading.
- “load is always better because it is safer.” It is later. Use it only when you need resources, not just elements.
- “async and defer work on inline scripts.” They matter for external classic scripts. Inline classic scripts run where they appear.
- “async scripts run after DOMContentLoaded.” They may run before or after it; the point is that download completion controls them.
- “beforeunload lets me write a custom warning.” Modern browsers show generic text, and the prompt is restricted.
- “unload is the right place for cleanup.” It is unreliable and harms bfcache. Prefer visibilitychange, pagehide, and sendBeacon for small work.
| Tool | Best for | Avoid using it for |
|---|---|---|
DOMContentLoaded | DOM exists; attach listeners | Waiting for images or final layout |
load | All page subresources loaded | Basic startup that could happen earlier |
visibilitychange | Pause, resume, quick save when hidden | Blocking navigation with a prompt |
pagehide | Navigation away, including bfcache checks | Long synchronous cleanup |
beforeunload | Unsaved-changes prompt only | Analytics, custom messages, or routine cleanup |
Practice exercises
5 EXERCISESWhat 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");The helper calls the callback immediately when readyState is not loading.
Type the event name returned by the program.
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"));Use visibilitychange for hidden/visible transitions such as tab switches.
Which attribute keeps external classic scripts ordered and runs them before DOMContentLoaded?
<script defer src="app.js"></script>defer downloads without blocking parsing, then executes after parsing and before DOMContentLoaded.
Which resource event handles a broken image?
img.addEventListener("error", showFallback);A broken image fires error, so use onerror or an error listener.
A teammate put analytics in window.addEventListener("unload", ...). Which event name should you tell them to avoid?
document.addEventListener("visibilitychange", saveIfHidden);
window.addEventListener("pagehide", sendFinalBeacon);Avoid unload for analytics or saving. Prefer visibilitychange, pagehide, and sendBeacon for short messages.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does DOMContentLoaded wait for?
Choose an answer to see the explanation.
Question 2 of 8What does this print in the model?
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 executes external classic scripts in document order after parsing 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 signal is best for pausing polling or animation when the user switches tabs?
Choose an answer to see the explanation.
Question 6 of 8How do modern browsers usually show a beforeunload warning?
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 unload for saving analytics?
Choose an answer to see the explanation.
Key takeaways
DOMContentLoadedis for parsed DOM;loadis for resources too.- Check
document.readyStatebefore adding a startup listener late. deferkeeps external classic scripts ordered before DOMContentLoaded;asyncdoes not.- Pause and save on
visibilitychange/pagehide; avoidunload. - Use resource
load/errorevents for individual images and files.
Page lifecycle work means matching each piece of JavaScript to the earliest browser moment where the facts it needs are true.
Up next: Form properties & elements.