Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

A script that runs too early

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');
});
What happened in three engines, and with three fixes JavaScript · too-early.mjs
// 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

Diagram: six loading milestones from parsing to load, with async scripts running at any point before load.1. Parsing: readyState "loading"the DOM grows as the HTML arrives;a classic script stops the parserwhile it downloads and runs2. Parsing ends:readyState "interactive"3. defer and module scripts run,in document order4. DOMContentLoaded5. Images, stylesheets, framesand async scripts finish6. readyState "complete",then load on windowAn async script runs as soon asit arrives, at any step before 6,and holds up load until it has run

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.

  1. 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.
  2. Parsing ends and readyState becomes "interactive".
  3. The scripts with defer and the module scripts run, in the order they appear in the document.
  4. DOMContentLoaded fires on the document.
  5. Images, stylesheets, frames and async scripts that are still loading finish.
  6. 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:

One report, loaded five ways

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 each script saw, in all nine loads JavaScript · what-ran.mjs
// 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.readyState was "loading", before #note existed.
  • The classic script at the end of the body also ran during "loading", because the parser had not finished yet, but everything above it, #note included, was already there.
  • The defer script and the module ran in the state "interactive", which means that parsing had ended, and before DOMContentLoaded: the event waits for them.
  • The async script, which the server held for 300 ms, ran after DOMContentLoaded. With a faster server it could have run before the event, even in the middle of parsing, and nothing promises either order.
  • document.currentScript was the running <script> element in the classic scripts and null in the module, where import.meta.url gives the module’s own address instead. At the top level of a module, this is undefined, and its declarations stay inside the module. In a classic script, this is window, and a top-level var became a property of window that any other script on the page could overwrite.
The four kinds of script
Criterion classicdeferasyncmodule
The parser waits while it downloads yesnonono
Runs when the parser reaches itafter parsing, before DOMContentLoadedas soon as it has arrivedafter parsing, before DOMContentLoaded
Keeps document order yesyes, with the other defer and module scriptsnoyes, with the defer scripts
Sees the whole document only what is above ityesnot promisedyes
Top-level names shared with every classic scriptshared with every classic scriptshared with every classic scriptprivate to the module
When to choose Rarely: code that must run before the page is shownMost page scriptsIndependent scripts, such as analyticsCode 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:

A page with a slow image and a deferred script

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}"`);
When each milestone came, in index.html and late.html JavaScript · ready-events.mjs
// 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:

An async script that arrives late

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():

Adding scripts from JavaScript

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');
}
The order in which the added scripts ran JavaScript · inserted.mjs
// 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:

A module with a top-level await

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`);
What waited for the await, and what did not JavaScript · module-await.mjs
// 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:

One long page, three places for the script

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`);
When the script was requested and ran, and when each part was parsed JavaScript · streaming.mjs
// 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:

Noting the events of leaving a page

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})`));
The events each engine fired JavaScript · leaving.mjs
// 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.

HTML, CSS & JS Playground (Online Editor) Paste too-early/index.html with basket.js, then add defer and watch the TypeError disappear.

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. If doc.readyState is still 'loading', it waits for the document's DOMContentLoaded event; 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. If doc.readyState is 'complete', the window's load event has already fired, so it resolves straight away; otherwise it waits for load on win. While the state is 'interactive', DOMContentLoaded has fired but load has not, so pageLoaded must 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.

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.

  1. Question 1 of 6 A script in the <head>, with no async, defer or type, runs document.querySelector('#total').textContent = '0', and #total is an element in the body. What happens?

    Choose one answer.

    Show the answer to question 1

    Answer: querySelector returns null, so the assignment throws a TypeError; the parser carries on with the rest of the page

    A classic script without attributes runs as soon as the parser reaches it, and only the elements above it exist then. querySelector returns null when nothing matches, and setting a property on null throws a TypeError, worded differently in each engine. The error ends that script only: the parser and the later scripts carry on, as footer.js did in all nine loads.

  2. Question 2 of 6 Which of these scripts have run by the time DOMContentLoaded fires?

    Choose every answer that is right.

    Show the answer to question 2

    Answer:

    • A module script
    • A classic script in the head
    • A script with defer
    • A classic script at the end of the body

    Classic scripts run while the document is being parsed, wherever they are, and the defer and module scripts run after parsing and before DOMContentLoaded, which waits for them. An async script runs whenever its file arrives; in the test, one that the server held for 300 ms ran after DOMContentLoaded.

  3. Question 3 of 6 Put these milestones of one page load in order. The page has a deferred script and a slow image.

    Give each item its position, from 1 (first).

    Show the answer to question 3

    Answer:

    1. readyState becomes "interactive"
    2. the defer script runs
    3. DOMContentLoaded fires
    4. the image finishes loading
    5. load fires on window

    Parsing ends first, which sets readyState to "interactive"; then the deferred scripts run, then DOMContentLoaded fires. The image holds up only the load event, which comes last, with readyState "complete". In the test the times were about 20, 315, 315, 1,217 and 1,218 ms in Chromium.

  4. Question 4 of 6 A script adds two scripts with document.createElement('script'): first a.js, which takes 600 ms to arrive, then b.js, which takes 100 ms. It sets nothing else. Which runs first?

    Choose one answer.

    Show the answer to question 4

    Answer: b.js, because added scripts are async unless async is set to false

    A script element created from JavaScript is async by default, so each one runs as soon as it arrives, and the faster b.js wins. Setting script.async = false on both puts them in the list of scripts that run in order as soon as possible, and then b.js waits for a.js.

  5. Question 5 of 6 This code is in an async script that arrives after the document has been parsed. What happens?

    Read the code, then choose one answer.

    document.addEventListener('DOMContentLoaded', () => {
      document.querySelector('#status').textContent = 'Ready';
    });
    Show the answer to question 5

    Answer: Nothing; DOMContentLoaded has already fired, so the listener never runs

    Events are not stored for late listeners: DOMContentLoaded fires once, and a listener added afterwards waits forever. Check document.readyState first, and run the code at once when it is not "loading", as whenReady() does.

  6. Question 6 of 6 Where should a page save a half-filled form so that the save also happens on phones?

    Choose one answer.

    Show the answer to question 6

    Answer: In a visibilitychange listener, when document.visibilityState becomes "hidden"

    visibilitychange with the state "hidden" fires when the page is hidden for any reason, including a switch to another app that is never followed by a return. unload is often not fired on phones, and MDN advises against it because a page with an unload listener may not be kept in the back/forward cache.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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.
  • defer and module scripts download in parallel and run after parsing, in document order, before DOMContentLoaded. async scripts run whenever they arrive, and hold up load.
  • readyState goes from "loading" to "interactive" when parsing ends and to "complete" with load. Check it before you add a listener for an event that may have fired already.
  • Scripts added with createElement('script') are async; set async = false to keep their order. import() loads a module on demand, and a top-level await delays only its own module.
  • Scripts at the end of the body avoid blocking, but the browser finds them late; defer in the head starts the download at once and ran 600 ms sooner in the test.
  • Save state on visibilitychange and pagehide, not unload.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.