JavaScript (ES2026) Module 15 – The DOM
How browsers load and run scripts
When a browser runs classic, defer, async and module scripts, what DOMContentLoaded and load wait for, and how to run code at the right moment.
What you will learn
- Choose between classic, defer, async and module scripts
- Run code at the right time with DOMContentLoaded, load and document.readyState
- Explain why scripts at the end of the body used to be recommended, and what defer does better
- Add scripts from JavaScript and keep them in order
- Save state when the reader leaves with pagehide and visibilitychange instead of unload
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between classic, defer, async and module scripts
- Run code at the right time with DOMContentLoaded, load and document.readyState
- Explain why scripts at the end of the body used to be recommended, and what defer does better
- Add scripts from JavaScript and keep them in order
- Save state when the reader leaves with pagehide and visibilitychange instead of unload
Before you start
On this page
A browser builds a page’s DOM, the tree of objects that scripts work with, while it reads the HTML from top to
bottom, and a script can only use the elements that the parser has built by the time it runs. Where a <script>
element sits and which attributes it has decide that moment. A classic script without attributes stops the parser
until it has downloaded and run, so it sees only what is above it. A defer script and a module script
(type="module") download while the parser carries on, and run once the whole document has been parsed, in document
order. An async script runs as soon as it has arrived, before or after parsing ends. Two events mark the stages:
DOMContentLoaded when the document has been parsed and the deferred scripts have run, and load when images,
stylesheets and the other resources have arrived too. The example pages here were each loaded three times in three
engines: Chromium 153, Firefox 155 and the WebKit of macOS 26.7.1, which is Safari’s engine. A small test server on the
same computer held chosen files back to imitate a slow network, and the programs next to the pages print what the
engines recorded.
What a script can see when it runs
This basket page loads basket.js in its head, and the script looks for the element that shows the total:
too-early/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Your basket</title>
<script src="basket.js"></script>
</head>
<body>
<h1>Your basket</h1>
<p>Total: <output id="total">0</output></p>
<script src="footer.js"></script>
</body>
</html> too-early/basket.js
// Shows the basket total. A classic script runs as soon as the parser reaches its <script> tag.
const total = document.querySelector('#total');
total.textContent = '₹1,299.00';
console.log('basket.js: total set'); too-early/footer.js
console.log(`footer.js ran; the total reads ${document.querySelector('#total').textContent}`); too-early/basket-listener.js
// The same job, started only once the whole document has been parsed.
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#total').textContent = '₹1,299.00';
console.log('basket-listener.js: total set');
}); // too-early.mjs: what happened when basket.js ran before <output id="total"> existed, and with each of three fixes,
// in the loads of recorded.mjs (three per engine).
import { engines, runs } from './recorded.mjs';
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
const texts = (load, kind) => load.log.filter(([k]) => k === kind).map(([, text]) => text);
console.log('index.html: basket.js runs while the parser is still in <head>');
let loads = 0;
let footerRan = 0;
let stillZero = 0;
for (const e of engines) {
const all = runs['too-early'][e.name];
// The message of the error event; Chromium's starts with "Uncaught ", as its console shows it, which is dropped here.
const messages = new Set(all.flatMap((l) => texts(l, 'uncaught')).map((m) => m.replace(/^Uncaught /, '')));
console.log(` ${pad(e.label, 23)}${[...messages].join(' | ')}`);
for (const l of all) {
loads++;
if (texts(l, 'log').some((t) => t.startsWith('footer.js ran'))) footerRan++;
if (l.total === '0') stillZero++;
}
}
console.log(` footer.js still ran in ${footerRan} of ${loads} loads; the total still read 0 in ${stillZero} of ${loads}`);
console.log('');
console.log('The same page with one change:');
const fixes = [
['too-early-defer', 'defer.html: <script src="basket.js" defer>'],
['too-early-module', 'module.html: <script src="basket.js" type="module">'],
['too-early-listener', 'listener.html: basket-listener.js waits for DOMContentLoaded'],
];
for (const [id, change] of fixes) {
let n = 0;
let set = 0;
let errors = 0;
let footerFirst = 0;
for (const e of engines) {
for (const l of runs[id][e.name]) {
n++;
if (l.total === '₹1,299.00') set++;
errors += texts(l, 'uncaught').length;
if (texts(l, 'log')[0].startsWith('footer.js ran')) footerFirst++;
}
}
console.log(` ${change}`);
console.log(` total set in ${set} of ${n} loads, ${errors} errors, footer.js ran first in ${footerFirst} of ${n}`);
} Output
index.html: basket.js runs while the parser is still in <head>
Chromium 153 TypeError: Cannot set properties of null (setting 'textContent')
Firefox 155 TypeError: can't access property "textContent", total is null
WebKit (macOS 26.7.1) TypeError: null is not an object (evaluating 'total.textContent = '₹1,299.00'')
footer.js still ran in 9 of 9 loads; the total still read 0 in 9 of 9
The same page with one change:
defer.html: <script src="basket.js" defer>
total set in 9 of 9 loads, 0 errors, footer.js ran first in 9 of 9
module.html: <script src="basket.js" type="module">
total set in 9 of 9 loads, 0 errors, footer.js ran first in 9 of 9
listener.html: basket-listener.js waits for DOMContentLoaded
total set in 9 of 9 loads, 0 errors, footer.js ran first in 9 of 9
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node too-early.mjs
When the parser reached the <script> tag, the <output id="total"> further down had not been parsed yet, so
document.querySelector('#total') returned null, and setting textContent on null threw a TypeError. Each
engine words that error differently, which helps when someone sends you a message from a browser you do not use:
“Cannot set properties of null” in Chromium, “total is null” in Firefox and “null is not an object” in WebKit all
mean that a lookup found nothing. The error stopped only basket.js. The parser carried on, footer.js ran in all
nine loads, and the total stayed 0.
Three one-line changes fixed the page without moving the script tag; defer.html, module.html and listener.html
in the same folder are copies of index.html with one change each. defer and type="module" postpone the whole
script until the document has been parsed, and a DOMContentLoaded listener, as in basket-listener.js, postpones
only the function inside it.
In every load the total was then set. Notice also that footer.js, a classic script at the end of the body, ran
before basket.js: a classic script runs when the parser reaches it, a deferred one only after the parser is done.
Classic, defer, async and module scripts
The milestones of loading a page, and where scripts run
Text description of the diagram
The diagram lists the milestones of loading a page from top to bottom.
- Parsing. document.readyState is "loading" and the DOM grows as the HTML arrives. A classic script without async or defer stops the parser while it downloads and runs.
- Parsing ends and readyState becomes "interactive".
- The scripts with defer and the module scripts run, in the order they appear in the document.
- DOMContentLoaded fires on the document.
- Images, stylesheets, frames and async scripts that are still loading finish.
- readyState becomes "complete", and then load fires on window.
A dashed box under the list adds that an async script runs as soon as its file has arrived, which can be at any step before the sixth, and that it holds up the load event until it has run.
To see what each kind of script finds, this page loads one report script in four ways in its head and once more at the end of its body, and each copy logs what it could see at that moment:
what-ran/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>What each script saw</title>
<script src="report.js"></script>
<script src="report.js?kind=defer" defer></script>
<script src="report.js?kind=async" async></script>
<script src="report.mjs" type="module"></script>
</head>
<body>
<h1>Departures</h1>
<p id="note">Platform changes are shown in bold.</p>
<script src="report.js?kind=end-of-body"></script>
</body>
</html> what-ran/report.js
// report.js: index.html loads this classic script four times; each copy reports what it could see when it ran.
if (!window.watching) {
window.watching = true;
document.addEventListener('DOMContentLoaded', () => {
window.sawDomContentLoaded = true;
console.log('DOMContentLoaded');
});
}
var kind = new URL(document.currentScript.src).searchParams.get('kind') ?? 'classic';
console.log(kind, JSON.stringify({
readyState: document.readyState,
noteParsed: document.querySelector('#note') !== null,
afterDomContentLoaded: window.sawDomContentLoaded === true,
currentScript: document.currentScript.outerHTML,
topLevelThis: this === window ? 'window' : String(this),
varIsGlobal: Object.hasOwn(window, 'kind'),
})); what-ran/report.mjs
// report.mjs: the same report from a module script.
const moduleKind = 'module';
console.log(moduleKind, JSON.stringify({
readyState: document.readyState,
noteParsed: document.querySelector('#note') !== null,
afterDomContentLoaded: window.sawDomContentLoaded === true,
currentScript: String(document.currentScript),
topLevelThis: String(this),
constIsGlobal: Object.hasOwn(window, 'moduleKind'),
importMetaUrl: new URL(import.meta.url).pathname,
})); // what-ran.mjs: what each kind of script in what-ran/index.html could see when it ran, in the loads of recorded.mjs.
import { engines, runs } from './recorded.mjs';
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
const LABELS = {
classic: 'classic, in <head>',
'end-of-body': 'classic, end of <body>',
defer: 'defer',
module: 'type="module"',
async: 'async (held 300 ms)',
};
const describe = (r) => [
`"${r.readyState}"`,
r.noteParsed ? 'yes' : 'no',
r.afterDomContentLoaded ? 'after' : 'before',
r.currentScript === 'null' ? 'null' : 'its element',
r.topLevelThis,
r.varIsGlobal ? 'yes' : 'no',
];
const seen = new Map();
let loads = 0;
for (const e of engines) {
for (const load of runs['what-ran'][e.name]) {
loads++;
for (const [, text] of load.log) {
const space = text.indexOf(' ');
if (space < 0) continue;
const kind = text.slice(0, space);
const report = JSON.parse(text.slice(space + 1));
if (kind === 'module') report.varIsGlobal = report.constIsGlobal;
const row = describe(report).join('|');
if (!seen.has(kind)) seen.set(kind, new Set());
seen.get(kind).add(row);
}
}
}
const widths = [25, 15, 13, 18, 16, 11, 0];
const header = ['Script', 'readyState', '#note parsed', 'DOMContentLoaded', 'currentScript', 'this', 'names on window'];
console.log(header.map((h, i) => (widths[i] ? pad(h, widths[i]) : h)).join(''));
for (const kind of ['classic', 'end-of-body', 'defer', 'module', 'async']) {
const rows = [...seen.get(kind)];
const cells = [LABELS[kind], ...rows[0].split('|')];
console.log(cells.map((c, i) => (widths[i] ? pad(c, widths[i]) : c)).join('') + (rows.length > 1 ? ' (differed between loads)' : ''));
}
console.log(`The same in every one of the ${loads} loads: ${[...seen.values()].every((s) => s.size === 1) ? 'yes' : 'no'}`); Output
Script readyState #note parsed DOMContentLoaded currentScript this names on window classic, in <head> "loading" no before its element window yes classic, end of <body> "loading" yes before its element window yes defer "interactive" yes before its element window yes type="module" "interactive" yes before null undefined no async (held 300 ms) "interactive" yes after its element window yes The same in every one of the 9 loads: yes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node what-ran.mjs
Read the table from the top:
- The classic script in the head ran while
document.readyStatewas"loading", before#noteexisted. - The classic script at the end of the body also ran during
"loading", because the parser had not finished yet, but everything above it,#noteincluded, was already there. - The
deferscript and the module ran in the state"interactive", which means that parsing had ended, and beforeDOMContentLoaded: the event waits for them. - The
asyncscript, which the server held for 300 ms, ran afterDOMContentLoaded. With a faster server it could have run before the event, even in the middle of parsing, and nothing promises either order. document.currentScriptwas the running<script>element in the classic scripts andnullin the module, whereimport.meta.urlgives the module’s own address instead. At the top level of a module,thisisundefined, and its declarations stay inside the module. In a classic script,thisiswindow, and a top-levelvarbecame a property ofwindowthat any other script on the page could overwrite.
| Criterion | classic | defer | async | module |
|---|---|---|---|---|
| The parser waits while it downloads | yes | no | no | no |
| Runs | when the parser reaches it | after parsing, before DOMContentLoaded | as soon as it has arrived | after parsing, before DOMContentLoaded |
| Keeps document order | yes | yes, with the other defer and module scripts | no | yes, with the defer scripts |
| Sees the whole document | only what is above it | yes | not promised | yes |
| Top-level names | shared with every classic script | shared with every classic script | shared with every classic script | private to the module |
| When to choose | Rarely: code that must run before the page is shown | Most page scripts | Independent scripts, such as analytics | Code written with import and export |
The HTML track’s lesson Adding CSS and JavaScript to a page measures how these four kinds order themselves under different network delays and how stylesheets hold scripts back. This lesson stays with what your code can rely on when it runs.
DOMContentLoaded and load
This page has a deferred script that the server held for 300 ms and an image that it held for 1,200 ms. timeline.js,
a classic script in the head, logs each milestone with performance.now(), the time in milliseconds since the page
started loading:
ready/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Today's trains</title>
<script src="timeline.js"></script>
<script src="app.js" defer></script>
</head>
<body>
<h1>Today's trains</h1>
<img src="train.svg" alt="A train at a platform" width="320" height="180">
</body>
</html> ready/timeline.js
// timeline.js: logs each loading milestone in milliseconds since the page started loading.
const at = () => Math.round(performance.now());
document.addEventListener('readystatechange', () => console.log(`readyState "${document.readyState}" at ${at()} ms`));
document.addEventListener('DOMContentLoaded', () => console.log(`DOMContentLoaded at ${at()} ms`));
document.addEventListener('load', (event) => {
if (event.target.localName === 'img') console.log(`image loaded at ${at()} ms`);
}, true);
window.addEventListener('load', () => console.log(`load at ${at()} ms`)); ready/app.js
console.log(`app.js (defer) ran at ${Math.round(performance.now())} ms, readyState "${document.readyState}"`); // ready-events.mjs: when each loading milestone of ready/index.html and ready/late.html happened, in the loads of
// recorded.mjs (milliseconds since the page started loading, median of three loads per engine).
import { engines, runs } from './recorded.mjs';
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const msOf = (text) => Number(/at (\d+) ms/.exec(text)?.[1] ?? NaN);
const MILESTONES = [
['readyState "interactive"', (t) => t.startsWith('readyState "interactive"')],
['app.js (defer) ran', (t) => t.startsWith('app.js (defer) ran')],
['DOMContentLoaded', (t) => t.startsWith('DOMContentLoaded')],
['image loaded', (t) => t.startsWith('image loaded')],
['readyState "complete"', (t) => t.startsWith('readyState "complete"')],
['load', (t) => t.startsWith('load at')],
];
console.log('ready/index.html (the server held app.js 300 ms and train.svg 1,200 ms):');
console.log(` ${pad('', 27)}${engines.map((e) => pad(e.label, 23)).join('').trimEnd()}`);
for (const [name, match] of MILESTONES) {
const cells = engines.map((e) => {
const times = runs.ready[e.name].map((l) => msOf(l.log.find(([, t]) => match(t))[1]));
return `${median(times)} ms`;
});
console.log(` ${pad(name, 27)}${cells.map((c) => pad(c, 23)).join('').trimEnd()}`);
}
const orders = engines.map((e) => {
const order = runs.ready[e.name].map((l) => l.log.map(([, t]) => MILESTONES.findIndex(([, m]) => m(t))).join(','));
return `${e.label}: ${new Set(order).size === 1 ? 'the same order in all 3 loads' : 'the order changed'}`;
});
console.log(` ${orders.join('; ')}`);
const wk = runs.ready.WebKit[0].log.map(([, t]) => MILESTONES.find(([, m]) => m(t))[0]);
console.log(` order in WebKit: ${wk.join(' → ')}`);
console.log('');
console.log('ready/late.html (the server held the async script late.js 600 ms):');
for (const e of engines) {
const loads = runs.late[e.name];
const dcl = median(loads.map((l) => msOf(l.log.find(([, t]) => t.startsWith('DOMContentLoaded'))[1])));
const ran = median(loads.map((l) => l.log.find(([, t]) => t.startsWith('late.js: whenReady'))[2]));
const listener = loads.filter((l) => l.log.some(([, t]) => t.includes('DOMContentLoaded listener ran'))).length;
const load = median(loads.map((l) => msOf(l.log.find(([, t]) => t.startsWith('load at'))[1])));
const status = new Set(loads.map((l) => l.status));
console.log(` ${pad(e.label, 23)}DOMContentLoaded ${dcl} ms, late.js ${ran} ms, load ${load} ms`);
console.log(` ${pad('', 23)}its DOMContentLoaded listener ran in ${listener} of 3 loads; whenReady() ran in 3 of 3; #status: "${[...status].join('", "')}"`);
} Output
ready/index.html (the server held app.js 300 ms and train.svg 1,200 ms):
Chromium 153 Firefox 155 WebKit (macOS 26.7.1)
readyState "interactive" 21 ms 83 ms 33 ms
app.js (defer) ran 315 ms 380 ms 335 ms
DOMContentLoaded 315 ms 380 ms 335 ms
image loaded 1217 ms 1279 ms 1244 ms
readyState "complete" 1217 ms 1279 ms 1244 ms
load 1218 ms 1279 ms 1244 ms
Chromium 153: the same order in all 3 loads; Firefox 155: the same order in all 3 loads; WebKit (macOS 26.7.1): the same order in all 3 loads
order in WebKit: readyState "interactive" → app.js (defer) ran → DOMContentLoaded → readyState "complete" → image loaded → load
ready/late.html (the server held the async script late.js 600 ms):
Chromium 153 DOMContentLoaded 19 ms, late.js 614 ms, load 615 ms
its DOMContentLoaded listener ran in 0 of 3 loads; whenReady() ran in 3 of 3; #status: "Seats loaded"
Firefox 155 DOMContentLoaded 51 ms, late.js 644 ms, load 645 ms
its DOMContentLoaded listener ran in 0 of 3 loads; whenReady() ran in 3 of 3; #status: "Seats loaded"
WebKit (macOS 26.7.1) DOMContentLoaded 37 ms, late.js 641 ms, load 641 ms
its DOMContentLoaded listener ran in 0 of 3 loads; whenReady() ran in 3 of 3; #status: "Seats loaded"
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node ready-events.mjs
The state became "interactive" as soon as the HTML had been parsed, after 21 to 83 ms, but DOMContentLoaded
waited for the deferred app.js and came at 315 to 380 ms. So a deferred script sees the state "interactive" and
runs before DOMContentLoaded. The image did not hold up DOMContentLoaded; it held up load, which came between
1,218 and 1,279 ms, together with the state "complete". The engines agreed on everything except one detail: WebKit
switched readyState to "complete" just before the image’s own load event, in the same millisecond, while the
other two fired the image’s event first.
Pick the event by what your code needs. Code that reads or changes elements needs a parsed document, so load it with
defer or as a module, or wait for DOMContentLoaded. Code that needs every image and stylesheet in place, such as
a script that measures the layout, waits for load on window.
Code that runs late
A listener for an event that has already fired never runs. The second page loads late.js with async, and the
server held it for 600 ms, long after DOMContentLoaded:
ready/late.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Seat map</title>
<script src="timeline.js"></script>
<script src="late.js" async></script>
</head>
<body>
<h1>Seat map</h1>
<p id="status">Loading seats…</p>
</body>
</html> ready/late.js
// late.js: an async script that arrives after the document has been parsed.
document.addEventListener('DOMContentLoaded', () => {
console.log('late.js: the DOMContentLoaded listener ran');
});
function whenReady(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback, { once: true });
} else {
callback();
}
}
whenReady(() => {
document.querySelector('#status').textContent = 'Seats loaded';
console.log(`late.js: whenReady ran its callback (readyState "${document.readyState}")`);
}); In all nine loads, the DOMContentLoaded listener of late.js never ran, while whenReady() ran its callback at
once, because it checks document.readyState first and only adds a listener while the state is still "loading".
The same check belongs in any code that may start late: a script that another script adds, a module loaded with
import(), a widget that a tag manager injects. Notice too that load came only after late.js had run. An async
script holds up the window’s load event, as an image does.
Scripts that JavaScript adds
A script can add more scripts. loader.js adds a.js and b.js twice, the second time with async set to
false, and loads a module with import():
inserted/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Widgets</title>
<script src="loader.js" defer></script>
</head>
<body>
<h1>Widgets</h1>
</body>
</html> inserted/loader.js
// loader.js: adds scripts from JavaScript. The test server held a.js back 600 ms and b.js 100 ms.
function addScript(src, { keepOrder = false } = {}) {
const script = document.createElement('script');
script.src = src;
if (keepOrder) script.async = false; // an inserted script is async unless you turn it off
document.head.append(script);
}
addScript('a.js?group=1');
addScript('b.js?group=1');
addScript('a.js?group=2', { keepOrder: true });
addScript('b.js?group=2', { keepOrder: true });
// A module loaded on demand: import() returns a promise for the module's exports.
import('./chart.mjs').then((chart) => chart.draw()); inserted/a.js
console.log(`a.js ran (group ${new URL(document.currentScript.src).searchParams.get('group')})`); inserted/b.js
console.log(`b.js ran (group ${new URL(document.currentScript.src).searchParams.get('group')})`); inserted/chart.mjs
export function draw() {
console.log('chart.mjs: draw() called');
} // inserted.mjs: in which order the scripts that loader.js added ran (inserted/index.html), in the loads of
// recorded.mjs. The server held a.js 600 ms and b.js 100 ms.
import { engines, runs } from './recorded.mjs';
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
for (const e of engines) {
const orders = runs.inserted[e.name].map((l) => {
const ran = l.log.map(([, text]) => text);
const group = (g) => ran.filter((t) => t.endsWith(`(group ${g})`)).map((t) => t.slice(0, 4)).join(' then ');
const chart = ran.findIndex((t) => t.startsWith('chart.mjs'));
return `added a, b: ${group(1)} | a, b with async = false: ${group(2)} | import() of chart.mjs ${chart === 0 ? 'resolved first' : 'resolved later'}`;
});
const same = new Set(orders).size === 1;
console.log(`${pad(e.label, 23)}${orders[0]}${same ? '' : ' (the loads differed)'}`);
} Output
Chromium 153 added a, b: b.js then a.js | a, b with async = false: a.js then b.js | import() of chart.mjs resolved first Firefox 155 added a, b: b.js then a.js | a, b with async = false: a.js then b.js | import() of chart.mjs resolved first WebKit (macOS 26.7.1) added a, b: b.js then a.js | a, b with async = false: a.js then b.js | import() of chart.mjs resolved first
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node inserted.mjs
A script element made with document.createElement('script') is async unless you say otherwise, so the faster
b.js ran first in every engine. With script.async = false, the browser kept the order in which the scripts were
added, and b.js waited for the slower a.js: the HTML Standard puts such scripts in a list of scripts that run in
order as soon as possible. import('./chart.mjs') returned a promise for the module’s exports, and the module ran
without waiting for the other scripts.
Modules that wait at the top level
A module may use await outside any function. settings.mjs waits 400 ms between its two log lines, standing in
for a request for the reader’s settings:
module-await/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Settings</title>
<script src="events.js"></script>
<script src="settings.mjs" type="module"></script>
<script src="after.js" defer></script>
</head>
<body>
<h1>Settings</h1>
</body>
</html> module-await/events.js
const at = () => Math.round(performance.now());
document.addEventListener('DOMContentLoaded', () => console.log(`DOMContentLoaded at ${at()} ms`));
window.addEventListener('load', () => console.log(`load at ${at()} ms`)); module-await/settings.mjs
// settings.mjs: a module that waits at its top level, here for a 400 ms timer standing in for a network request.
console.log(`settings.mjs: started at ${Math.round(performance.now())} ms`);
await new Promise((resolve) => setTimeout(resolve, 400));
console.log(`settings.mjs: finished its await at ${Math.round(performance.now())} ms`); module-await/after.js
console.log(`after.js (defer) ran at ${Math.round(performance.now())} ms`); // module-await.mjs: when module-await/index.html's milestones happened (median of three loads per engine, in ms
// since the page started loading). settings.mjs waits 400 ms at its top level.
import { engines, runs } from './recorded.mjs';
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const STEPS = [
['settings.mjs started', 'settings.mjs: started'],
['after.js (defer) ran', 'after.js'],
['DOMContentLoaded', 'DOMContentLoaded'],
['load', 'load at'],
['settings.mjs finished its await', 'settings.mjs: finished'],
];
console.log(`${pad('', 33)}${engines.map((e) => pad(e.label, 23)).join('').trimEnd()}`);
for (const [name, prefix] of STEPS) {
const cells = engines.map((e) => `${median(runs['module-await'][e.name].map((l) => l.log.find(([, t]) => t.startsWith(prefix))[2]))} ms`);
console.log(`${pad(name, 33)}${cells.map((c) => pad(c, 23)).join('').trimEnd()}`);
}
const sameOrder = engines.every((e) => runs['module-await'][e.name].every((l) => l.log.map(([, t]) => STEPS.findIndex(([, p]) => t.startsWith(p))).join() === '0,1,2,3,4'));
console.log(`This order in all 9 loads: ${sameOrder ? 'yes' : 'no'}`); Output
Chromium 153 Firefox 155 WebKit (macOS 26.7.1) settings.mjs started 17 ms 168 ms 158 ms after.js (defer) ran 18 ms 168 ms 158 ms DOMContentLoaded 18 ms 172 ms 158 ms load 18 ms 176 ms 158 ms settings.mjs finished its await 419 ms 570 ms 558 ms This order in all 9 loads: yes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node module-await.mjs
The module started on time, and after.js, a defer script listed after it, ran straight after the module’s first
part. Neither DOMContentLoaded nor load waited for the await: the second half of settings.mjs ran about
400 ms later, in all nine loads. Code that needs the settings must wait for that module itself, for example by
importing it, rather than for DOMContentLoaded.
Why scripts used to go at the end of the body
For a long time the advice was to put scripts just before </body>, and Steve Souders’ book High Performance Web
Sites made it one of its rules. A classic script in the head stops the parser, so the browser can show nothing below
it until the script has downloaded and run. At the end of the body, the script can no longer hold up the content
above it, and every element exists when it runs. This test shows what that placement still costs. The server sent a
long timetable in five parts, 300 ms apart, as a slow connection would, and held app.js for 600 ms. The three
copies of the page differ only in where the script is:
streaming/head-classic.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Long timetable</title>
<script src="app.js"></script>
</head>
<body>
<h1>Long timetable</h1>
<p>Part 1 of 5: trains from 05:00 to 08:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 2 of 5: trains from 09:00 to 11:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 3 of 5: trains from 12:00 to 15:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 4 of 5: trains from 16:00 to 19:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 5 of 5: trains from 20:00 to 23:59.</p>
</body>
</html> streaming/end-of-body.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Long timetable</title>
</head>
<body>
<h1>Long timetable</h1>
<p>Part 1 of 5: trains from 05:00 to 08:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 2 of 5: trains from 09:00 to 11:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 3 of 5: trains from 12:00 to 15:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 4 of 5: trains from 16:00 to 19:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 5 of 5: trains from 20:00 to 23:59.</p>
<script src="app.js"></script>
</body>
</html> streaming/head-defer.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Long timetable</title>
<script src="app.js" defer></script>
</head>
<body>
<h1>Long timetable</h1>
<p>Part 1 of 5: trains from 05:00 to 08:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 2 of 5: trains from 09:00 to 11:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 3 of 5: trains from 12:00 to 15:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 4 of 5: trains from 16:00 to 19:59.</p>
<!-- pause: the test server waited 300 ms here -->
<p>Part 5 of 5: trains from 20:00 to 23:59.</p>
</body>
</html> streaming/app.js
console.log(`app.js ran at ${Math.round(performance.now())} ms and found ${document.querySelectorAll('p').length} of 5 parts`); // streaming.mjs: three copies of a long page that arrived in five parts, 300 ms apart, with app.js held 600 ms:
// when app.js was requested and ran, and when each part was parsed (median of three loads per engine).
import { engines, runs } from './recorded.mjs';
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const PAGES = [
['stream-head-classic', 'head-classic.html: <script src="app.js"> in <head>'],
['stream-end-of-body', 'end-of-body.html: <script src="app.js"> before </body>'],
['stream-head-defer', 'head-defer.html: <script src="app.js" defer> in <head>'],
];
for (const [id, title] of PAGES) {
console.log(title);
for (const e of engines) {
const loads = runs[id][e.name];
const asked = median(loads.map((l) => {
const page = l.requests.find(([path]) => path.endsWith('.html'))[1];
return l.requests.find(([path]) => path.endsWith('app.js'))[1] - page;
}));
const ran = median(loads.map((l) => l.log[0][2]));
const found = new Set(loads.map((l) => /found (\d) of 5/.exec(l.log[0][1])[1]));
const parts = [0, 1, 2, 3, 4].map((i) => median(loads.map((l) => l.parsed[i])));
console.log(` ${pad(e.label, 23)}requested at ${pad(`${asked} ms,`, 9)}ran at ${pad(`${ran} ms`, 9)}(saw ${[...found].join('/')} parts); parts parsed at ${parts.join(', ')} ms`);
}
} Output
head-classic.html: <script src="app.js"> in <head> Chromium 153 requested at 7 ms, ran at 616 ms (saw 0 parts); parts parsed at 616, 616, 616, 907, 1207 ms Firefox 155 requested at 69 ms, ran at 676 ms (saw 0 parts); parts parsed at 677, 677, 677, 924, 1207 ms WebKit (macOS 26.7.1) requested at 14 ms, ran at 625 ms (saw 0 parts); parts parsed at 625, 626, 626, 912, 1212 ms end-of-body.html: <script src="app.js"> before </body> Chromium 153 requested at 1213 ms, ran at 1820 ms (saw 5 parts); parts parsed at 8, 304, 605, 909, 1208 ms Firefox 155 requested at 1207 ms, ran at 1811 ms (saw 5 parts); parts parsed at 81, 322, 624, 925, 1207 ms WebKit (macOS 26.7.1) requested at 1209 ms, ran at 1824 ms (saw 5 parts); parts parsed at 17, 309, 610, 911, 1211 ms head-defer.html: <script src="app.js" defer> in <head> Chromium 153 requested at 8 ms, ran at 1209 ms (saw 5 parts); parts parsed at 10, 306, 607, 908, 1209 ms Firefox 155 requested at 78 ms, ran at 1207 ms (saw 5 parts); parts parsed at 41, 324, 623, 928, 1207 ms WebKit (macOS 26.7.1) requested at 15 ms, ran at 1212 ms (saw 5 parts); parts parsed at 17, 309, 609, 911, 1212 ms
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node streaming.mjs
In the head without attributes, the script was requested at once, but no part of the timetable was parsed until it
had run, at about 620 to 680 ms, so nothing below it could be shown before then. At the end of the body, the parts
were parsed as they arrived, but the browser only learned about the script when the last part came, at about
1,210 ms, so the script ran at about 1,810 to 1,820 ms. With defer in the head, the browser asked for the script at
once, parsed every part as it arrived, and ran the script at about 1,210 ms, the moment parsing ended. That is about
600 ms sooner than at the end of the body, with the same complete document to work on, which is why defer in the
head, or a module, is the better default today.
When the reader leaves the page
Pages often save something when the reader leaves: a half-filled form, the scroll position, a last report for
analytics. The unload event looks like the place for it, but MDN advises against it. Browsers, especially on phones,
often do not fire it, and a page with an unload listener may be kept out of the back/forward cache, which restores
a page instantly when the reader goes back. Listen for pagehide and visibilitychange instead:
leaving/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Booking form</title>
<script src="save-draft.js" defer></script>
</head>
<body>
<h1>Booking form</h1>
<p><a href="next.html">Continue to payment</a></p>
</body>
</html> leaving/save-draft.js
// save-draft.js: notes which events fired while the reader left the page (sessionStorage survives the navigation).
function note(text) {
const log = JSON.parse(sessionStorage.getItem('events') ?? '[]');
log.push(text);
sessionStorage.setItem('events', JSON.stringify(log));
}
document.addEventListener('visibilitychange', () => note(`visibilitychange (${document.visibilityState})`));
window.addEventListener('pagehide', (event) => note(`pagehide (persisted: ${event.persisted})`));
window.addEventListener('pageshow', (event) => note(`pageshow (persisted: ${event.persisted})`)); // leaving.mjs: which events save-draft.js noted when the reader followed the link on leaving/index.html, and, after
// going back, on its return (the loads of recorded.mjs).
import { engines, runs } from './recorded.mjs';
const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length));
for (const [id, title] of [['leaving', 'Following the link to next.html:'], ['leaving-back', 'Following the link, then going back:']]) {
console.log(title);
for (const e of engines) {
const seen = new Set(runs[id][e.name].map((l) => JSON.parse(l.events).join(' → ')));
console.log(` ${pad(e.label, 23)}${[...seen].join(' | ')}${seen.size === 1 ? ' (all 3 loads)' : ''}`);
}
} Output
Following the link to next.html: Chromium 153 pagehide (persisted: false) → visibilitychange (hidden) (all 3 loads) Firefox 155 pagehide (persisted: false) → visibilitychange (hidden) (all 3 loads) WebKit (macOS 26.7.1) pagehide (persisted: true) → visibilitychange (hidden) (all 3 loads) Following the link, then going back: Chromium 153 pagehide (persisted: false) → visibilitychange (hidden) → pageshow (persisted: false) (all 3 loads) Firefox 155 pagehide (persisted: false) → visibilitychange (hidden) → pageshow (persisted: false) (all 3 loads) WebKit (macOS 26.7.1) pagehide (persisted: true) → visibilitychange (hidden) → visibilitychange (hidden) → pageshow (persisted: true) (all 3 loads)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node leaving.mjs
All three engines fired pagehide and then visibilitychange with the state "hidden" when the link was followed.
In WebKit, persisted: true says that the page went into the back/forward cache, and going back brought it out
again with a pageshow event whose persisted was true as well. The automated Chromium and Firefox of this test
loaded the page afresh instead, so do not assume either outcome. Save your state when visibilitychange reports
"hidden", which also covers a reader who switches to another app on a phone and never returns, and use pageshow
with persisted to refresh anything that may be out of date when a page comes back from the cache.
Scripts in the MySmartCoPilot playground
The HTML, CSS & JS Playground puts its JavaScript tab into a classic <script> at
the end of the body, so the elements of the HTML tab already exist when your code runs. To try a module, write
<script type="module">…</script> in the HTML tab. The preview also runs inline handlers such as onclick="…", but a
page that sends a strict Content Security Policy, as this site’s own pages do, blocks them, so add listeners with
addEventListener from a script file instead.
Exercise
Exercise · Easy · JavaScript
Wait for the DOM and for the whole page, even when you arrive late
Write two functions in ready.mjs that return promises, so that a script can await the right moment however late it starts:
domReady(doc)resolves once the document has been parsed. Ifdoc.readyStateis still'loading', it waits for the document'sDOMContentLoadedevent; if it is'interactive'or'complete', that event has already fired, so it resolves straight away.pageLoaded(doc, win)resolves once the whole page has loaded. Ifdoc.readyStateis'complete', the window'sloadevent has already fired, so it resolves straight away; otherwise it waits forloadonwin. While the state is'interactive',DOMContentLoadedhas fired butloadhas not, sopageLoadedmust keep waiting.
Add each listener with { once: true }, so that it removes itself after the event. In a page you would call domReady(document) and pageLoaded(document, window). The sample tests run without a browser, so they pass small stand-ins for document and window that have a readyState (the document only) and addEventListener, and fire the events themselves.
Starter code · ready.mjs
/**
* Resolves once the document has been parsed: at once when readyState is "interactive" or "complete", otherwise on
* DOMContentLoaded.
* @param {{ readyState: string, addEventListener: Function }} doc
* @returns {Promise<void>}
*/
export function domReady(doc) {
return new Promise((resolve) => {
doc.addEventListener('DOMContentLoaded', resolve, { once: true });
});
}
/**
* Resolves once the page has loaded: at once when readyState is "complete", otherwise on the window's load event.
* @param {{ readyState: string }} doc
* @param {{ addEventListener: Function }} win
* @returns {Promise<void>}
*/
export function pageLoaded(doc, win) {
return new Promise((resolve) => {
doc.addEventListener('DOMContentLoaded', resolve, { once: true });
});
} The sample tests · ready.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { domReady, pageLoaded } from './ready.mjs';
// Stand-ins for document and window: they record listeners, and fire(type) calls them the way a browser does
// (a listener added with { once: true } is removed after its first call).
function target(extra = {}) {
const listeners = [];
return {
...extra,
listeners,
addEventListener(type, fn, options) {
listeners.push({ type, fn, once: !!(options && options.once) });
},
removeEventListener(type, fn) {
const i = listeners.findIndex((l) => l.type === type && l.fn === fn);
if (i >= 0) listeners.splice(i, 1);
},
fire(type) {
for (const l of [...listeners]) {
if (l.type !== type) continue;
if (l.once) listeners.splice(listeners.indexOf(l), 1);
l.fn({ type });
}
},
};
}
// Whether a promise has settled after the pending callbacks have run.
async function settled(promise) {
let done = false;
promise.then(() => { done = true; });
for (let i = 0; i < 5; i++) await Promise.resolve();
return done;
}
test('domReady resolves at once when the document is already parsed', async () => {
assert.ok(await settled(domReady(target({ readyState: 'interactive' }))), 'readyState "interactive"');
assert.ok(await settled(domReady(target({ readyState: 'complete' }))), 'readyState "complete"');
});
test('domReady waits for DOMContentLoaded while the document is loading', async () => {
const doc = target({ readyState: 'loading' });
const ready = domReady(doc);
assert.equal(await settled(ready), false, 'it must not resolve before DOMContentLoaded');
doc.readyState = 'interactive';
doc.fire('DOMContentLoaded');
assert.ok(await settled(ready), 'it resolves on DOMContentLoaded');
});
test('pageLoaded waits for load on the window, not for DOMContentLoaded', async () => {
const doc = target({ readyState: 'interactive' });
const win = target();
const loaded = pageLoaded(doc, win);
doc.fire('DOMContentLoaded');
assert.equal(await settled(loaded), false, 'DOMContentLoaded has fired, but the page is still loading');
doc.readyState = 'complete';
win.fire('load');
assert.ok(await settled(loaded), 'it resolves on load');
});
test('pageLoaded resolves at once when the page has already loaded', async () => {
assert.ok(await settled(pageLoaded(target({ readyState: 'complete' }), target())));
});
test('listeners remove themselves after the event', async () => {
const doc = target({ readyState: 'loading' });
const win = target();
domReady(doc);
pageLoaded(doc, win);
assert.ok(doc.listeners.length + win.listeners.length > 0, 'something is listening while the page loads');
assert.ok([...doc.listeners, ...win.listeners].every((l) => l.once), 'add each listener with { once: true }');
doc.fire('DOMContentLoaded');
win.fire('load');
assert.equal(doc.listeners.length + win.listeners.length, 0, 'no listener is left behind');
}); A hint
new Promise((resolve) => { … }) gives you a resolve function to call at the right moment. Check doc.readyState first: when the moment has already passed, call resolve() at once; otherwise pass resolve itself as the listener, target.addEventListener(type, resolve, { once: true }). Only pageLoaded listens on win.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between DOMContentLoaded and load?
DOMContentLoaded fires on the document once the HTML has been parsed and the defer and module scripts have run.
It does not wait for images, frames or async scripts, but every element exists by then, so code that reads or changes
the DOM can start. load fires on window later, when everything the page loads has finished, images and async
scripts included, and document.readyState becomes "complete". On a test page whose image took 1,200 ms to arrive,
DOMContentLoaded came after about 300 ms, once a slow deferred script had run, and load after about 1,200 ms. A
listener for either event that is added after the event has fired never runs, so code that may start late checks
document.readyState first: "loading" means wait for DOMContentLoaded; any other value means the DOM is ready.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A chat widget is loaded with <script async src="widget.js"> and starts inside document.addEventListener('DOMContentLoaded', init). On the office network it always appears; on slower phone connections it often never appears, and nothing is logged. What is going on, and how do you fix it?
-
Mid to senior
A product page has four classic scripts in its head: vendor.js, app.js, which needs vendor.js, reviews.js, which needs app.js, and an analytics snippet. On a slow phone the page stays blank for about two seconds. How would you load these scripts, and what would you measure before and after?
Key takeaways
- A script sees only the elements parsed before it runs. A classic script without attributes runs mid-parse and stops the parser while it downloads.
deferand module scripts download in parallel and run after parsing, in document order, beforeDOMContentLoaded.asyncscripts run whenever they arrive, and hold upload.readyStategoes from"loading"to"interactive"when parsing ends and to"complete"withload. Check it before you add a listener for an event that may have fired already.- Scripts added with
createElement('script')are async; setasync = falseto keep their order.import()loads a module on demand, and a top-levelawaitdelays only its own module. - Scripts at the end of the body avoid blocking, but the browser finds them late;
deferin the head starts the download at once and ran 600 ms sooner in the test. - Save state on
visibilitychangeandpagehide, notunload.
References
- HTML Standard: the script element (WHATWG)
- HTML Standard: the defer and async attributes (WHATWG)
- HTML Standard: the end of parsing (WHATWG)
- HTML Standard: document.readyState (WHATWG)
- HTML Standard: document.currentScript (WHATWG)
- HTML Standard: running a module script (WHATWG)
- HTML Standard: page lifecycle (unloading documents) (WHATWG)
- Document: DOMContentLoaded event (MDN Web Docs)
- Window: load event (MDN Web Docs)
- <script>: The Script element (MDN Web Docs)
- import() (MDN Web Docs)
- Window: unload event (MDN Web Docs)
- Document: visibilitychange event (MDN Web Docs)
- Window: pagehide event (MDN Web Docs)
- Back/forward cache (Google (web.dev))
- High Performance Web Sites, Rule 6: Put Scripts at the Bottom (the book's companion site) (Steve Souders)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress