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.

HTML  Module 11 – Scripts, styles, performance and security

Adding CSS and JavaScript to a page

Link stylesheets, load scripts with defer, async or type="module" and predict when each runs, and swap onclick attributes for listeners a strict CSP allows.

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

What you will learn

  • Link stylesheets and embed styles in the right place
  • Load scripts with defer, async or type="module" and predict their order
  • Replace inline event-handler attributes with scripts that a strict CSP allows
  • Use import maps and noscript where they help

Before you start

What you’ll learn · Before you start

What you will learn

  • Link stylesheets and embed styles in the right place
  • Load scripts with defer, async or type="module" and predict their order
  • Replace inline event-handler attributes with scripts that a strict CSP allows
  • Use import maps and noscript where they help

Before you start

On this page

CSS goes into a page through a <link rel="stylesheet" href="…"> element in the <head>, which loads a separate .css file; a <style> element in the head holds CSS for that one page. JavaScript goes in with <script src="…">, and its attributes decide when it runs. Without attributes, a script stops the HTML parser until it has downloaded and run. With defer, it downloads while the parser carries on and runs once the page has been parsed, in document order. With async, it runs the moment it has arrived, so several async scripts run in whatever order their downloads finish. type="module" loads a JavaScript module, which is deferred in the same way. A good default for most pages: stylesheets early in the head, scripts in the head with defer or type="module", async only for scripts that depend on nothing, and no inline onclick attributes, because a strict Content Security Policy blocks them.

This lesson shows each rule in pages that were loaded in Chromium 153, Firefox 155 and WebKit (the engine of Safari, as macOS 26.7.1 ships it) from a local test server that could hold back any file for a set time. The engines agree on most rules and disagree on a few, and the disagreements are worth knowing before they surprise you.

Linking stylesheets

Put <link rel="stylesheet" href="/css/site.css"> in the <head>. The HTML Standard makes a stylesheet that the parser finds there render-blocking: the browser does not paint the page until the file has arrived, which is what you want, because nobody should see the page unstyled and then watch it jump. A media attribute narrows that: <link rel="stylesheet" href="print.css" media="print"> still downloads, but it does not hold up the screen, because its media query does not match. A <style> element in the head blocks rendering in the same way without a request of its own, which makes it a fit for CSS that only one page needs. The HTML Standard now also allows a <style> element in the body as the first child of its parent, provided it has no @import and its style rules sit inside a bare @scope block, so that it can only style its own parent and what follows; @scope is Baseline newly available.

The CSS track compares the three ways to add CSS, measures @import chains and explains the browser’s default styles in Adding CSS to HTML. This lesson stays with what HTML decides: where the elements go and how they interact with scripts.

Scripts wait for the stylesheets above them

A script may ask for computed styles or sizes, so the Standard makes classic scripts wait for any stylesheet that the parser met before them. In this test the server held slow.css for 800 ms, and the next element in the head was a script that logged when it ran and what colour the stylesheet had given the heading:

A slow stylesheet, then a script (defer.html) HTML · stylesheet-first/defer.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A stylesheet, then a defer script</title>
  <link rel="stylesheet" href="slow.css">
  <script src="measure.js" defer></script>
</head>
<body>
  <h1>Order tracker</h1>
</body>
</html>
When the script ran, in three engines JavaScript · stylesheet-wait.mjs
// Reads recorded.json: a stylesheet that took 800 ms to arrive, then one script of each kind (stylesheet-first/).
// Prints when the script ran (median of 3 loads) and whether it saw the stylesheet's heading colour.
import { readFileSync } from 'node:fs';

const { delayMs, results } = JSON.parse(readFileSync(new URL('./recorded.json', import.meta.url), 'utf8')).stylesheet;
const label = (e) => (e.engine === 'WebKit' ? `WebKit (macOS ${e.os.match(/[\d.]+/)[0]})` : `${e.engine} ${e.version.split('.')[0]}`);
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const GREEN = 'rgb(11, 110, 79)';

console.log(`slow.css arrived after ${delayMs} ms. When did measure.js run?\n`);
const kinds = Object.keys(results[0].kinds);
const cell = (lines) => {
  const ms = median(lines.map((l) => Number(l[0].match(/ran at (\d+) ms/)[1])));
  const styled = lines.every((l) => l[0].includes(GREEN));
  const parsed = lines.every((l) => !l[0].includes('before the heading'));
  return `${String(ms).padStart(4)} ms ${!parsed ? 'no heading yet' : styled ? 'styled' : 'unstyled'}`;
};
console.log(`${'script'.padEnd(10)}${results.map((e) => label(e).padEnd(26)).join('')}`.trimEnd());
for (const kind of kinds) {
  console.log(`${kind.padEnd(10)}${results.map((e) => cell(e.kinds[kind]).padEnd(26)).join('')}`.trimEnd());
}

Output

slow.css arrived after 800 ms. When did measure.js run?

script    Chromium 153              Firefox 155               WebKit (macOS 26.7.1)
classic    811 ms no heading yet     829 ms no heading yet     810 ms no heading yet
defer      810 ms styled             828 ms styled              11 ms unstyled
async        9 ms unstyled            47 ms unstyled             9 ms unstyled
module     809 ms styled             834 ms styled              10 ms unstyled

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node stylesheet-wait.mjs

The script without attributes ran at about 810 to 830 ms in every engine, before the heading had been parsed: the stylesheet stopped the parser too, through the script. An async script did not wait for the stylesheet at all. defer and module scripts waited in Chromium and Firefox and saw the heading styled, but WebKit ran them about 10 ms after the start, before the stylesheet had arrived, and they saw the default black. The Standard says deferred scripts also wait for such stylesheets, so WebKit is the odd one out here. For your pages this means that a deferred script which reads styles works in two engines and not in the third, so a script that measures layout should wait for the window’s load event, or for the load event of the stylesheet it depends on.

Classic, defer, async and module scripts

Timelines of four kinds of script: when each downloads and runs while the browser parses the HTML.When a script downloads and runsclassic<script src>parser waitsdefer<script defer>async<script async>moduletype="module"and its importstime →parser: reading HTMLscript: downloadingscript: running

When each kind of script downloads and runs

Text description of the diagram

Four rows share one time axis. Each row has two lanes: the parser reading the HTML, and the script being downloaded and then run.

  1. A classic script without attributes: the parser stops when it reaches the script and waits while the file downloads and runs, then carries on parsing.
  2. defer: the parser never stops. The file downloads while the HTML is parsed, and the script runs once parsing has finished.
  3. async: the parser keeps going while the file downloads. The script runs as soon as it has arrived, which pauses the parser for as long as it runs, so it can run before or after the end of parsing.
  4. type="module": like defer, the module and the modules it imports download while the HTML is parsed, and it runs once parsing has finished, in document order with the defer scripts.

The four choices differ in whether the parser stops while the file downloads, when the script runs, and whether several scripts of the kind keep their order:

Kind Parser waits Runs Order kept
Plain Yes At once, mid-parse Yes
defer No After parsing Yes
async No When it arrives No
Module No After parsing Yes

Plain means a classic script with neither async nor defer, and Module means type="module". Module scripts also get their own scope and strict mode, which the other three do not. In short: defer for most page scripts, type="module" for code written with import and export, async only for independent scripts such as analytics, and no attribute only for the rare script that must run before the page renders.

A classic script without attributes is the oldest behaviour: when the parser reaches it, everything stops until the file has downloaded and run, and the Standard also blocks rendering on it. A script in the head can therefore delay the first paint by its whole download time. defer and module scripts join one list, which the browser runs in document order after it has finished parsing and before it fires DOMContentLoaded. An async script is fetched while the parser carries on and runs as soon as possible after its file arrives, which may be before or after parsing ends.

Three small rules complete the picture. If a script has both async and defer, async wins; defer is there only as a fallback for very old browsers. On a classic script without src, async and defer do nothing: an inline script runs at once. And defer has no effect on modules, which are deferred anyway, while async does work on them, inline ones included.

Watching the order in three engines

This page loads one script of each kind. Each script logs its name, document.readyState and whether the <main> element had been parsed yet:

The test page and two of its scripts

order/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>When does each script run?</title>
  <script src="events.js"></script>
  <script src="async-a.js" async></script>
  <script src="async-b.js" async></script>
  <script src="defer.js" defer></script>
  <script src="module.js" type="module"></script>
  <script src="classic.js"></script>
</head>
<body>
  <main>
    <h1>Order tracker</h1>
    <p>Open the console to see when each script ran.</p>
  </main>
</body>
</html>

order/events.js

// Logs the two events that mark the end of parsing and the end of loading.
/* global document, window */
document.addEventListener('DOMContentLoaded', () => console.log('DOMContentLoaded'));
window.addEventListener('load', () => console.log('load'));

order/classic.js

/* global document */
console.log(
  'classic.js ran: readyState "' + document.readyState + '",',
  document.querySelector('main') ? '<main> parsed' : '<main> not parsed yet',
);

The page was loaded three times in each engine under two network conditions, in which the server held some files back. This program reads what was logged and checks the Standard’s rules against every load:

The order in which the scripts ran JavaScript · script-order.mjs
// Reads what order/index.html logged in three browser engines (recorded.json) and checks the rules the HTML
// Standard gives for classic, defer, async and module scripts against every load.
import { readFileSync } from 'node:fs';

const recorded = JSON.parse(readFileSync(new URL('./recorded.json', import.meta.url), 'utf8'));
const { networks, runs } = recorded.order;

const label = (e) => (e.engine === 'WebKit' ? `WebKit (macOS ${e.os.match(/[\d.]+/)[0]})` : `${e.engine} ${e.version.split('.')[0]}`);
const short = (line) => line.replace(/\.js ran:.*$/, '');
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)];

let loads = 0;
const counts = { classic: 0, deferred: 0, loadLast: 0 };
const asyncPlaces = new Set();

for (const [name, delays] of Object.entries(networks)) {
  const held = Object.entries(delays)
    .map(([path, ms]) => `${path.replace('/order/', '')} ${ms} ms`)
    .join(', ');
  console.log(`Network ${name}: the server held ${held}; other files came at once`);
  for (const engine of runs[name]) {
    const orders = engine.loads.map((l) => l.ran.map(short));
    const same = orders.every((o) => o.join() === orders[0].join());
    console.log(`  ${pad(label(engine), 24)}${orders[0].join(' → ')}${same ? '' : '  (the loads differed)'}`);
    for (const [i, order] of orders.entries()) {
      const ran = engine.loads[i].ran;
      loads++;
      const classic = ran.find((l) => l.startsWith('classic.js'));
      if (classic.includes('"loading"') && classic.includes('not parsed yet')) counts.classic++;
      const d = order.indexOf('defer');
      const m = order.indexOf('module');
      const dcl = order.indexOf('DOMContentLoaded');
      const after = [d, m].every((k) => ran[k].includes('"interactive"') && ran[k].includes('<main> parsed'));
      if (d < m && m < dcl && after) counts.deferred++;
      if (order.at(-1) === 'load') counts.loadLast++;
      asyncPlaces.add(`a${order.indexOf('async-a')}b${order.indexOf('async-b')}`);
    }
  }
  console.log('');
}

const files = ['events.js', 'async-a.js', 'async-b.js', 'defer.js', 'module.js', 'classic.js'];
console.log('When each engine asked for each file in network B (ms after the page request, median of 3 loads):');
console.log(`  ${pad('', 24)}${files.map((f) => pad(f.replace('.js', ''), 9)).join('')}`.trimEnd());
for (const engine of runs.B) {
  const cells = files.map((f) => String(median(engine.loads.map((l) => l.requests.find((r) => r.path === `/order/${f}`).sentAt))));
  console.log(`  ${pad(label(engine), 24)}${cells.map((c) => pad(c, 9)).join('')}`.trimEnd());
}
console.log('');

console.log(`Checked in all ${loads} loads:`);
console.log(`  classic.js ran before <main> was parsed (readyState "loading"):        ${counts.classic} of ${loads}`);
console.log(`  defer.js, then module.js, after parsing and before DOMContentLoaded:   ${counts.deferred} of ${loads}`);
console.log(`  load came last:                                                         ${counts.loadLast} of ${loads}`);
console.log(`  async-a.js and async-b.js: ${asyncPlaces.size} different placements in ${loads} loads`);

Output

Network A: the server held async-a.js 100 ms, async-b.js 1500 ms, classic.js 600 ms; other files came at once
  Chromium 153            async-a → classic → defer → module → DOMContentLoaded → async-b → load
  Firefox 155             classic → async-a → defer → module → DOMContentLoaded → async-b → load
  WebKit (macOS 26.7.1)   classic → defer → module → DOMContentLoaded → async-a → async-b → load

Network B: the server held async-a.js 1500 ms, async-b.js 100 ms, classic.js 600 ms, defer.js 900 ms; other files came at once
  Chromium 153            classic → async-b → async-a → defer → module → DOMContentLoaded → load
  Firefox 155             classic → async-b → defer → module → DOMContentLoaded → async-a → load
  WebKit (macOS 26.7.1)   classic → defer → module → DOMContentLoaded → async-b → async-a → load

When each engine asked for each file in network B (ms after the page request, median of 3 loads):
                          events   async-a  async-b  defer    module   classic
  Chromium 153            7        8        611      612      7        8
  Firefox 155             27       28       28       28       28       28
  WebKit (macOS 26.7.1)   6        9        9        9        10       10

Checked in all 18 loads:
  classic.js ran before <main> was parsed (readyState "loading"):        18 of 18
  defer.js, then module.js, after parsing and before DOMContentLoaded:   18 of 18
  load came last:                                                         18 of 18
  async-a.js and async-b.js: 6 different placements in 18 loads

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node script-order.mjs

The rules held in all 18 loads: the classic script ran mid-parse, and defer.js ran before module.js once parsing had finished, before DOMContentLoaded. The async scripts, though, landed in six different places. In network A, async-a.js arrived after 100 ms while the parser was waiting for classic.js. Chromium ran it at once, Firefox right after classic.js, and WebKit only after DOMContentLoaded. The request times explain a second surprise. Chrome’s documentation on fetch priority gives async and defer scripts a low priority and describes a first, layout-blocking phase in which Chromium loads some low-priority files one at a time. In network B it asked for async-b.js and defer.js only when classic.js had arrived and the parser moved on, so async-a.js, the file the server held longest, still ran before defer.js. The resource hints lesson of this module explains these priorities and how to change them.

The lesson for your own pages: never let one async script depend on another, or on a defer script. If scripts depend on each other, load them all with defer or as modules, in the order they need, and the browser keeps that order on any network.

Head with defer, or end of body?

Older advice put scripts at the end of the <body> so that they could not block the page. A defer script in the <head> does better: the browser learns about it as soon as it reads the head and starts the download while it parses the rest, and the script still runs only after parsing, with the whole page available. A classic script at the end of the body no longer holds up the content above it, but the browser may only find it once the end of a long document has arrived, and DOMContentLoaded still waits until it has downloaded and run.

Modules

<script type="module" src="app.mjs"> loads a JavaScript module. A module can import from other modules and export what it defines; its top-level names stay inside the module instead of becoming globals, and module code is always strict mode code. The scope difference is easy to see. Two classic scripts that both declare const total collide in the shared global scope, and two modules that do the same do not:

Two classic scripts and two modules declare the same name

modules/scope.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Two scripts, one name</title>
  <script src="cart.js"></script>
  <script src="wishlist.js"></script>
  <script type="module" src="cart.mjs"></script>
  <script type="module" src="wishlist.mjs"></script>
</head>
<body>
  <p>Open the console.</p>
</body>
</html>

modules/cart.js

const total = 3;
console.log('cart.js: total is', total);

modules/wishlist.js

const total = 5;
console.log('wishlist.js: total is', total);

Modules are also fetched with CORS rules, so a module from another site loads only if that site allows it, and a page opened straight from your disk may not load its modules at all. This test opened the same module page from a file:// address and from a local server:

A module page

modules/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A page with one module</title>
  <script type="module" src="app.mjs"></script>
</head>
<body>
  <p id="total">Waiting for the module…</p>
</body>
</html>

modules/app.mjs

/* global document */
import { rupees } from './money.mjs';

document.querySelector('#total').textContent = `Total: ${rupees(1299)}`;
console.log('app.mjs ran');

modules/money.mjs

// Formats a number of rupees the way readers in India write it: ₹1,299.00, ₹1,29,999.00.
const format = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' });

export function rupees(amount) {
  return format.format(amount);
}
What the module tests recorded JavaScript · modules-results.mjs
// Reads recorded.json: modules/index.html opened from a file and from a local server, and modules/scope.html, where
// two classic scripts and two modules each declare `const total`.
import { readFileSync } from 'node:fs';

const { modules } = JSON.parse(readFileSync(new URL('./recorded.json', import.meta.url), 'utf8'));
const name = (e) => (e.engine === 'WebKit' ? 'WebKit' : `${e.engine} ${e.version.split('.')[0]}`);

console.log('modules/index.html: did app.mjs run?');
for (const e of modules) {
  const ran = (r) => (r.text.startsWith('Total:') ? `yes, "${r.text}"` : 'no');
  console.log(`  ${name(e).padEnd(14)} from file://: ${ran(e.file).padEnd(25)} from http://: ${ran(e.http)}`);
}
const blocked = modules.find((e) => e.engine === 'Chromium').file.engineConsole.find((m) => m.text.includes('CORS'));
if (blocked) console.log(`  Chromium's console said: ${blocked.text.slice(0, blocked.text.indexOf('CORS policy') + 'CORS policy'.length)}`);

console.log('\nmodules/scope.html: what each script logged or threw');
for (const e of modules) {
  console.log(`  ${name(e)}`);
  for (const m of e.scope.log) console.log(`    ${m.type === 'pageerror' ? 'error: ' : ''}${m.text}`);
}

Output

modules/index.html: did app.mjs run?
  Chromium 153   from file://: no                        from http://: yes, "Total: ₹1,299.00"
  Firefox 155    from file://: yes, "Total: ₹1,299.00"   from http://: yes, "Total: ₹1,299.00"
  WebKit         from file://: no                        from http://: yes, "Total: ₹1,299.00"
  Chromium's console said: Access to script at 'file:///lesson/examples/modules/app.mjs' from origin 'null' has been blocked by CORS policy

modules/scope.html: what each script logged or threw
  Chromium 153
    cart.js: total is 3
    error: Uncaught SyntaxError: Identifier 'total' has already been declared
    cart.mjs: total is 3
    wishlist.mjs: total is 5
  Firefox 155
    cart.js: total is 3
    error: SyntaxError: redeclaration of const total
    cart.mjs: total is 3
    wishlist.mjs: total is 5
  WebKit
    cart.js: total is 3
    error: SyntaxError: Can't create duplicate variable: 'total'
    cart.mjs: total is 3
    wishlist.mjs: total is 5

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node modules-results.mjs

From file://, Chromium refused the module with a CORS error and WebKit did not run it either; only Firefox did. Every engine ran it from http://. So test module pages through a local web server. In the page’s folder, python3 -m http.server --bind 127.0.0.1 serves it at http://127.0.0.1:8000/ and keeps it on your own computer (without --bind, Python’s server answers on every network interface), and most editors have a live-server extension that does the same. The duplicate const total threw a SyntaxError in all three engines, each worded differently, while both modules logged their own total.

Import maps

A module imports another by a URL relative to itself, such as './money.mjs'. An import map lets your modules use a short name instead, a bare specifier such as 'money', and the page decides which file that name means. Change one line of the map and every module that imports 'money' loads the new version.

An import map and a module that uses it

import-map/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Prices with an import map</title>
  <script type="importmap">
    {
      "imports": {
        "money": "./lib/money-v2.mjs"
      }
    }
  </script>
  <script type="module" src="prices.mjs"></script>
</head>
<body>
  <p id="price">Loading the price…</p>
</body>
</html>

import-map/prices.mjs

/* global document */
import { rupees } from 'money';

document.querySelector('#price').textContent = `Laptop bag: ${rupees(1299)}, laptop: ${rupees(129999)}`;
console.log('prices.mjs ran');

lib/money-v2.mjs · import-map/lib/money-v2.mjs

// Version 2 of the shared formatting module. Pages import it as "money"; the import map decides which file that is.
const format = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' });

export function rupees(amount) {
  return format.format(amount);
}

Indian number grouping

Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }) writes amounts the way readers in India expect: the module printed ₹1,299.00 and ₹1,29,999.00, with the lakh grouping, in all three engines. No library was needed.

Import maps are Baseline widely available, so every current browser supports them. A few rules keep them working. The map must be an inline <script type="importmap">: the Standard does not support an external map file and fires an error event if you give one a src. Put it before the first module script, and use one map per page: browsers that merge several maps exist, but merging is not Baseline yet. Without a map, or when a map is blocked, a bare specifier fails with a TypeError that each engine words differently, as the output in the next section shows.

Inline event handlers and a strict Content Security Policy

An attribute such as onclick="refreshStatus()" is a small inline script. A Content Security Policy (CSP), a response header that lists where a page may load code from, treats it that way: with script-src 'self', the page may run script files from its own site, and nothing written inline. This page has one button with an inline handler and one whose listener is added by a script file:

An inline handler and a listener

handlers/before.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Refresh the order status</title>
  <script src="status.js" defer></script>
</head>
<body>
  <button type="button" onclick="refreshStatus()">Refresh with onclick</button>
  <button type="button" id="refresh">Refresh with a listener</button>
  <p id="status">Not refreshed yet</p>
</body>
</html>

handlers/status.js

// Shows when the status was refreshed, and logs every Content Security Policy violation on the page.
/* global document */
function refreshStatus() {
  document.querySelector('#status').textContent = 'Refreshed';
  console.log('refreshStatus() ran');
}

document.querySelector('#refresh').addEventListener('click', refreshStatus);

document.addEventListener('securitypolicyviolation', (event) => {
  console.log(`Blocked by ${event.effectiveDirective}: ${event.blockedURI} "${event.sample}"`);
});
The same pages with and without a policy JavaScript · policy-results.mjs
// Reads recorded.json: the handlers/ pages and the import-map/ page, served without a policy, with
// Content-Security-Policy: script-src 'self', and with a hash that allows one inline piece of code.
import { readFileSync } from 'node:fs';

const { handlers, importMap } = JSON.parse(readFileSync(new URL('./recorded.json', import.meta.url), 'utf8'));
const engines = (res) => res.map((e) => e.engine).join(', ');

console.log('handlers/before.html: what did each click do?');
for (const [policy, res] of Object.entries(handlers.results)) {
  console.log(`  ${policy === 'none' ? 'no policy' : handlers.policies[policy]}`);
  const rows = new Map();
  for (const e of res) {
    for (const [button, status] of e.pages['before.html'].clicks) {
      const key = `    ${button.padEnd(26)}→ ${status === 'Refreshed' ? 'refreshed' : 'nothing happened'}`;
      rows.set(key, [...(rows.get(key) ?? []), e.engine]);
    }
  }
  for (const [row, who] of rows) console.log(`${row}${who.length === res.length ? '' : ` (${who.join(', ')})`}`);
  const reported = res.flatMap((e) => e.pages['before.html'].log.filter((m) => m.type === 'csp').map((m) => m.directive));
  if (reported.length) console.log(`    reported violation: ${[...new Set(reported)].join(', ')} in ${engines(res)}`);
}

console.log('\nimport-map/index.html: the price line');
for (const [policy, res] of Object.entries(importMap.results)) {
  const texts = [...new Set(res.map((e) => e.text))];
  const label = policy === 'none' ? 'no policy' : importMap.policies[policy].replace(/'sha256-[^']+'/, "'sha256-…'");
  console.log(`  ${label.padEnd(36)} ${texts.length === 1 ? texts[0] : texts.join(' / ')}`);
}
console.log('\nThe error each engine threw when the map was blocked or missing:');
for (const e of importMap.results.strict) {
  const error = e.log.find((m) => m.type === 'pageerror');
  console.log(`  ${e.engine.padEnd(9)} ${error.text}`);
}

Output

handlers/before.html: what did each click do?
  no policy
    Refresh with onclick      → refreshed
    Refresh with a listener   → refreshed
  script-src 'self'
    Refresh with onclick      → nothing happened
    Refresh with a listener   → refreshed
    reported violation: script-src-attr in Chromium, Firefox, WebKit
  script-src 'self' 'unsafe-hashes' 'sha256-TjwEk4YeV6xkuxlg+M8QKnh3sxTw4CriC/NFkh/k+Tk='
    Refresh with onclick      → refreshed
    Refresh with a listener   → refreshed

import-map/index.html: the price line
  no policy                            Laptop bag: ₹1,299.00, laptop: ₹1,29,999.00
  script-src 'self'                    Loading the price…
  script-src 'self' 'sha256-…'         Laptop bag: ₹1,299.00, laptop: ₹1,29,999.00

The error each engine threw when the map was blocked or missing:
  Chromium  Uncaught TypeError: Failed to resolve module specifier "money". Relative references must start with either "/", "./", or "../".
  Firefox   TypeError: The specifier “money” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”.
  WebKit    TypeError: Module name, 'money' does not resolve to a valid URL.

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node policy-results.mjs

Under script-src 'self', the inline handler did nothing in all three engines, and each reported a violation of script-src-attr, the part of the policy that covers event-handler attributes; the listener from status.js worked. The inline import map was refused as well, because the policy treats it as an inline script, so the module could not resolve 'money'; adding the map’s SHA-256 hash to the policy let it through. 'unsafe-hashes' with a hash of refreshStatus() made the inline handler work again. The CSP Level 3 specification describes 'unsafe-hashes' as a help for legacy sites that should be avoided in modern ones, and the OWASP Content Security Policy Cheat Sheet advises replacing inline handlers with addEventListener calls. Moving to modules breaks inline handlers in a second way: functions declared in a module are not globals, so onclick="refreshStatus()" on the module page threw a ReferenceError in every engine even without a policy.

The fix is the same for both problems: give the element an id or a class, and add the listener from a script file.

The page without inline code

handlers/after.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Refresh the order status</title>
  <script src="refresh.js" defer></script>
</head>
<body>
  <button type="button" id="refresh">Refresh</button>
  <p id="status">Not refreshed yet</p>
</body>
</html>

handlers/refresh.js

/* global document */
const output = document.querySelector('#status');

document.querySelector('#refresh').addEventListener('click', () => {
  output.textContent = 'Refreshed';
});

The HTML, CSS & JS Playground of this site runs inline handlers in its preview so that you can experiment freely; this site’s own pages send a strict policy and use none. The security lesson of this module shows how to write such a policy.

Content Security Policy (CSP) Generator Build a script-src and style-src policy for your own page and check what it allows.

noscript and progressive enhancement

Content inside <noscript> is shown only when scripting is off. In the head it may hold only <link>, <style> and <meta> elements; in the body, any content. It suits a short note about what does not work without JavaScript, but a page is more robust when its essential content is plain HTML and scripts only add to it. This is progressive enhancement. In this delivery page, the status is in the HTML, the note explains what is missing, and the script shows a button that only works when it runs:

A note for readers without JavaScript

noscript/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Delivery status</title>
  <script src="live.js" defer></script>
</head>
<body>
  <h1>Delivery status</h1>
  <p>Out for delivery: arriving by 7 pm.</p>
  <noscript>
    <p>Live tracking needs JavaScript. Reload the page for the latest status.</p>
  </noscript>
  <button type="button" id="track" hidden>Track live</button>
</body>
</html>

noscript/live.js

// Progressive enhancement: the button is useful only when this script runs, so the script shows it.
/* global document */
document.querySelector('#track').hidden = false;

With JavaScript on, Chromium and Firefox showed the status and the Track live button and nothing from <noscript>. With JavaScript switched off, they showed the status and the note, and the button stayed hidden, so nobody saw a control that could not work.

Try it: three scripts in order

Exercise · Easy · JavaScript

Make three scripts run in order without blocking the page

tracker.mjs holds the markup of an order-tracking page as a string, page. It loads three scripts:

  • config.js sets up the settings that the other two read;
  • app.js reads them, then finds the Refresh button with document.querySelector('#refresh') and adds a click listener to it;
  • analytics.js reports what app.js has set up, so it must run last.

Rewrite the markup so that:

  1. the three scripts run in the order config.js, app.js, analytics.js on every load, whichever of their downloads finishes first;
  2. none of them stops the parser while it downloads, and all three stay in the <head>;
  3. no element has an inline event-handler attribute such as onclick (a strict Content Security Policy would block it), and the button is a type="button" with the id that app.js looks for.

The sample tests read page with a small markup reader and work out the order by the HTML Standard's rules for many different download times, so keep the quotes around attribute values.

Starter code · tracker.mjs

// The order tracker. Edit the markup between the backticks; the sample tests read it as text.
export const page = `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Order tracker</title>
  <script src="analytics.js" async></script>
  <script src="app.js" async></script>
  <script src="config.js"></script>
</head>
<body>
  <h1>Your order</h1>
  <p id="status">Packed, waiting for pickup</p>
  <button onclick="refresh()">Refresh</button>
</body>
</html>`;
The sample tests · tracker.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { page } from './tracker.mjs';

// A small markup reader for these tests: start tags with their attributes, in document order, and whether each one
// comes before </head>. It skips comments and the text inside script elements. It is not a full HTML parser, so keep
// the quotes around attribute values.
function startTags(html) {
  const tags = [];
  const headEnd = html.search(/<\/head\s*>/i);
  const tag = /<!--[\s\S]*?-->|<([a-zA-Z][\w-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
  let m;
  while ((m = tag.exec(html))) {
    if (!m[1]) continue;
    const attrs = {};
    for (const a of m[2].matchAll(/([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) {
      const name = a[1].toLowerCase();
      if (!(name in attrs)) attrs[name] = a[2] ?? a[3] ?? a[4] ?? '';
    }
    const name = m[1].toLowerCase();
    tags.push({ name, attrs, inHead: headEnd < 0 || m.index < headEnd });
    if (name === 'script') {
      const end = html.slice(tag.lastIndex).search(/<\/script\s*>/i);
      if (end >= 0) tag.lastIndex += end;
    }
  }
  return tags;
}

const tags = startTags(page);
const scripts = tags
  .filter((t) => t.name === 'script')
  .map((t) => ({
    name: (t.attrs.src ?? 'an inline script').split('/').pop(),
    src: 'src' in t.attrs,
    async: 'async' in t.attrs,
    defer: 'defer' in t.attrs,
    module: (t.attrs.type ?? '').trim().toLowerCase() === 'module',
    inHead: t.inHead,
  }));

// The HTML Standard's rules, simplified: the parser reaches the scripts one after another; a classic script without
// async or defer stops it until the file has arrived and run; async scripts run as soon as they have arrived; defer
// and module scripts wait for the end of parsing and then run in document order.
function runOrder(arrivals) {
  let parser = 0;
  const runs = [];
  const deferred = [];
  scripts.forEach((s, i) => {
    const ready = s.src ? arrivals[s.name] ?? 0 : parser;
    if (s.async && (s.src || s.module)) runs.push({ name: s.name, at: Math.max(ready, parser), seq: i });
    else if (s.module || (s.defer && s.src)) deferred.push({ name: s.name, ready, seq: i });
    else {
      parser = Math.max(parser, ready);
      runs.push({ name: s.name, at: parser, seq: i });
    }
  });
  let at = parser;
  for (const d of deferred) {
    at = Math.max(at, d.ready);
    runs.push({ name: d.name, at, seq: 1000 + d.seq });
  }
  return runs.sort((a, b) => a.at - b.at || a.seq - b.seq).map((r) => r.name);
}

const FILES = ['config.js', 'app.js', 'analytics.js'];
const TIMES = [[50, 300, 800], [50, 800, 300], [300, 50, 800], [300, 800, 50], [800, 50, 300], [800, 300, 50]];

test('the three scripts are still there, once each, in the head', () => {
  for (const file of FILES) {
    const found = scripts.filter((s) => s.name === file);
    assert.equal(found.length, 1, `${file} should be loaded exactly once`);
    assert.ok(found[0].inHead, `${file} should stay in the <head>`);
  }
});

test('none of them stops the parser while it downloads', () => {
  for (const s of scripts.filter((x) => FILES.includes(x.name))) {
    assert.ok(s.async || s.defer || s.module, `${s.name} is a classic script without async or defer: the parser waits for it`);
  }
});

test('config.js, app.js, analytics.js run in that order, whatever arrives first', () => {
  for (const [config, app, analytics] of TIMES) {
    const order = runOrder({ 'config.js': config, 'app.js': app, 'analytics.js': analytics }).filter((n) => FILES.includes(n));
    assert.deepEqual(order, FILES, `with downloads finishing after ${config}, ${app} and ${analytics} ms the order was ${order.join(', ')}`);
  }
});

test('no inline event-handler attributes are left', () => {
  for (const t of tags) {
    for (const name of Object.keys(t.attrs)) assert.ok(!name.startsWith('on'), `<${t.name}> still has ${name}`);
  }
});

test('the Refresh button is a type="button" with the id app.js looks for', () => {
  const buttons = tags.filter((t) => t.name === 'button');
  assert.equal(buttons.length, 1, 'keep exactly one button');
  assert.equal(buttons[0].attrs.type, 'button');
  assert.equal(buttons[0].attrs.id, 'refresh');
});
A hint

Which kinds of script keep their document order? async scripts run as soon as each one arrives, so they cannot promise an order. Look at the other attribute that lets the parser carry on, and remember that the button's handler now lives in app.js.

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.

HTML, CSS & JS Playground (Online Editor) Paste a page and its scripts, open the console, and watch the order change as you swap defer for async.

Check yourself

5 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 5 Which of these scripts wait until the HTML has been parsed and then run in the order they appear in the page?

    Choose every answer that is right.

    Show the answer to question 1

    Answer:

    • <script type="module" src="b.js">
    • <script src="a.js" defer>

    Classic scripts with defer and module scripts without async go on the same list: they download while the parser carries on, and once parsing has finished they run one after another in document order, before DOMContentLoaded. An async script runs as soon as it has arrived, so its place is not fixed, and a classic script without either attribute stops the parser until it has downloaded and run.

  2. Question 2 of 5 A page's head holds, in this order, <script src="theme.js" defer>, <script src="polyfills.js"> and <script type="module" src="app.js">. All three files come back at once. Put the three, and the DOMContentLoaded event, in the order they happen.

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

    Show the answer to question 2

    Answer:

    1. polyfills.js
    2. theme.js
    3. app.js
    4. DOMContentLoaded

    polyfills.js is a classic script without async or defer, so it runs while the page is still being parsed. theme.js (defer) and app.js (a module) both wait for the end of parsing and then run in document order, and DOMContentLoaded fires after the last of them.

  3. Question 3 of 5 What does defer do on this script?

    Read the code, then choose one answer.

    <script defer>
      document.documentElement.classList.add('js');
    </script>
    Show the answer to question 3

    Answer: Nothing. defer applies only to scripts with a src attribute, so this one runs as soon as the parser reaches it

    The HTML Standard lets defer change a classic script only when the script has a src attribute. An inline classic script always runs at once, while the parser waits, whatever async or defer say. An inline <script type="module"> is the exception in the other direction: modules are deferred by default.

  4. Question 4 of 5 A page is served with Content-Security-Policy: script-src 'self'. It has <button onclick="save()"> and a second button whose click listener is added in /app.js. What happens when each button is clicked?

    Choose one answer.

    Show the answer to question 4

    Answer: The onclick button does nothing and logs a script-src-attr violation; the listener button works

    Without 'unsafe-inline' (or 'unsafe-hashes' with a hash of the handler), the policy refuses to compile inline handler attributes, and the browser reports the directive script-src-attr. A listener added by a script file from the page's own origin is ordinary code from an allowed source, so it runs. Chromium, Firefox and WebKit all behaved this way in this lesson's test.

  5. Question 5 of 5 You change <script src="status.js" defer> to <script type="module" src="status.js">. Since then, <button onclick="refreshStatus()"> throws a ReferenceError. Why?

    Choose one answer.

    Show the answer to question 5

    Answer: Functions declared at the top of a module stay inside that module, so the inline handler cannot find them

    A classic script's top-level function becomes a property of the global object, which inline handlers look up. A module has its own scope, so refreshStatus is not global any more. The robust fix is the one this lesson recommends anyway: add the listener in the module with addEventListener.

Interview questions

Fresher · What is the difference between async and defer on a script element?

Both make a classic script with a src download while the HTML parser carries on, so neither stops the page from being parsed. They differ in when the script runs. A defer script waits until the whole document has been parsed, then runs in document order with the other defer scripts and before DOMContentLoaded, so it can rely on the full DOM and on scripts listed before it. An async script runs as soon as its file has arrived, which can be before or after parsing ends, and several async scripts run in whatever order they arrive. Use defer for scripts that need the page or each other, and async only for independent code such as analytics. Both attributes do nothing on inline scripts, and modules behave like defer by default.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    Two scripts in a shop's head use async: cart.js fills a cart object and analytics.js reports it. Some visitors' reports are empty, others are fine. Explain why it depends on the visitor, and change the markup so that it always works without delaying the first paint.

    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 Content-Security-Policy of script-src 'self' goes live on an admin dashboard. Two buttons stop working, nothing else breaks, and the console shows script-src-attr violations. What broke, how do you fix it properly, and which quick fixes would you turn down?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Link stylesheets in the <head>: they block rendering until they arrive, which prevents a flash of unstyled content; a stylesheet whose media does not match does not block.
  • Classic scripts wait for stylesheets above them; defer and module scripts did too in Chromium and Firefox, but not in WebKit, so scripts that measure layout should wait for load.
  • Without attributes, a script stops the parser while it downloads and runs. defer and modules run after parsing, in document order. async runs when it arrives, in an order that changed with the engine and the network.
  • Modules have their own scope and strict mode, load with CORS, and need an http(s) address in Chromium and WebKit.
  • Import maps give modules short names; keep one inline map before the first module, and give it a hash under a strict policy.
  • Replace onclick and other handler attributes with addEventListener in a script file: a strict CSP blocks them, and modules cannot see globals they never declared.
  • Keep essential content in HTML, add behaviour with scripts, and use <noscript> for a short note.

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.