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.
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:
<!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> // 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
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.
- 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.
- defer: the parser never stops. The file downloads while the HTML is parsed, and the script runs once parsing has finished.
- 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.
- 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:
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:
// 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:
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:
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);
} // 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.
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:
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}"`);
}); // 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.
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:
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.jssets up the settings that the other two read;app.jsreads them, then finds the Refresh button withdocument.querySelector('#refresh')and adds a click listener to it;analytics.jsreports whatapp.jshas set up, so it must run last.
Rewrite the markup so that:
- the three scripts run in the order
config.js,app.js,analytics.json every load, whichever of their downloads finishes first; - none of them stops the parser while it downloads, and all three stay in the
<head>; - no element has an inline event-handler attribute such as
onclick(a strict Content Security Policy would block it), and the button is atype="button"with theidthatapp.jslooks 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
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.
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.
-
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.
-
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?
Key takeaways
- Link stylesheets in the
<head>: they block rendering until they arrive, which prevents a flash of unstyled content; a stylesheet whosemediadoes 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.
deferand modules run after parsing, in document order.asyncruns 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
onclickand other handler attributes withaddEventListenerin 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
- HTML Standard: the link element (WHATWG)
- HTML Standard: link type "stylesheet" (WHATWG)
- HTML Standard: the style element (WHATWG)
- HTML Standard: the script element (WHATWG)
- HTML Standard: the noscript element (WHATWG)
- HTML Standard: import maps (WHATWG)
- HTML Standard: the end of parsing (WHATWG)
- HTML Standard: render-blocking mechanism (WHATWG)
- Content Security Policy Level 3 (W3C Web Application Security Working Group)
- ECMAScript Language Specification: strict mode code (Ecma International TC39)
- Content Security Policy Cheat Sheet (OWASP)
- Optimize resource loading with the Fetch Priority API (Google (web.dev))
- http.server: command-line interface (Python Software Foundation)
- Web features explorer: import maps (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress