MENU
9. Web APIs
The DOM of Chapter 8 lets a script change the page. Everything else a web app does, from fetching and storing data to running threads, playing media, reading sensors and measuring its own speed, goes through the Web APIs the browser exposes to JavaScript.The chapter opens with the rules these APIs share: specifications, secure contexts, permissions and support. It then tours the API families: network, storage, workers, interface, media, device, security, observers and performance, then graphics, built-in AI and polyfills. It closes with third-party embeds and browser extensions.What you will learn:How secure contexts, permissions and user activation gate powerful APIs
Talking to servers with Fetch, streams, WebSocket, WebRTC and WebTransport
Storing data, running workers and building offline-capable apps
Using media, device, security and observer APIs, and measuring Core Web Vitals
Integrating payments, maps and analytics, and building browser extensions
9.1 The Web Platform APIs
Every API in this chapter has a specification: WHATWG living standards such as HTML, DOM, Fetch, Streams, URL and Storage; W3C documents such as Permissions and Geolocation; and WICG drafts for new ideas. Interfaces are written in Web IDL, and the "Specifications" table on each MDN page links to the text. Before you build on an API, ask: does it need a secure context, does it need permission, and is it supported? A fourth question has joined them: what does it let other sites learn about your user? Browsers now key storage and network state by top-level site, and the privacy APIs built around that shift are covered with the security ones in Section 9.8.A secure context is a window or worker whose origin is potentially trustworthy: https: and wss: URLs, file: URLs, and localhost, *.localhost, 127.0.0.0/8 or ::1. A frame is secure only if its own URL is trustworthy and its parent is a secure context. Features marked [SecureContext] in Web IDL do not exist elsewhere: on a plain http:// page, headless Chrome 152 reports isSecureContext as false and has no navigator.serviceWorker, navigator.clipboard or crypto.subtle, while navigator.geolocation exists but fails with "Only secure origins are allowed". Because localhost counts as secure, code that works on your machine can break when a phone opens http://192.168.1.20:5173.Powerful features pass three gates. First, the Permissions Policy (Section 2.8.6) must allow the feature, or its permission reads denied and no prompt ever appears. Second, the user must grant it. navigator.permissions.query({ name }) resolves to a PermissionStatus whose state is granted, denied or prompt, with a change event. query() never prompts; calling the feature, such as getCurrentPosition(), does. The API is Baseline Widely available (since March 2025), but each browser accepts different names and throws a TypeError for the rest.Third, many APIs need user activation. A trusted keydown, mousedown, pointerup or touchend grants short-lived transient activation, required by window.open(), fullscreen, clipboard access and file pickers, and permanent sticky activation, checked by audio autoplay and navigator.vibrate(). navigator.userActivation.isActive and .hasBeenActive expose both (Widely available since May 2026). Ask for permissions from a click handler, when the user understands why.<!doctype html>
<style>body { font: 16px system-ui; } code { background: #eceff1; }</style>
<ul id="out"></ul>
<script type="module">
const out = document.getElementById('out');
const show = (label, value) =>
out.insertAdjacentHTML('beforeend', `<li><code>${label}</code> â <b>${value}</b></li>`);
show('isSecureContext', `${isSecureContext} (${location.protocol})`);
show("'serviceWorker' in navigator", 'serviceWorker' in navigator);
show("CSS.supports('anchor-name: --a')", CSS.supports('anchor-name: --a'));
show('userActivation.hasBeenActive', navigator.userActivation.hasBeenActive);
for (const name of ['geolocation', 'clipboard-write', 'nfc']) {
try {
const status = await navigator.permissions.query({ name });
show(`query '${name}'`, status.state);
} catch (err) {
show(`query '${name}'`, err.name); // name not supported here
}
}
</script>
Figure 9.1: Browser output of Listing 9.1A file: URL counts as secure. Chrome grants clipboard-write without
asking and rejects nfc on desktop.Test for the capability, never the browser name: in for objects,
CSS.supports() or @supports for CSS, try/catch for option values. If a
test fails, hide the feature or load a polyfill (Section 9.13).Baseline (Section 1.1.3) tells you when you can drop the fallback. In
September 2026 webstatus.dev tracks 1,191 features: 646 Widely
available, 117 Newly available and 428 with limited availability. Its
data feeds the <baseline-status featureId="permissions"> web
component (npm baseline-status) for your own docs.9.2 Network
Scripts talk to servers in the background so the page never reloads, a technique still called AJAX (Asynchronous JavaScript And XML) although payloads are now mostly JSON. Section 1.1.1 covers the HTTP underneath.Most requests are pull: the browser asks, the server answers. Push lets the server send news at once. Before browsers had push APIs, Comet techniques faked it with polling and long polling, where each request stays open until data arrives and the browser immediately asks again; long polling remains Socket.IO's fallback transport.9.2.1 AJAX with XMLHttpRequest
XMLHttpRequest (XHR) began at Microsoft as the engine of Outlook Web Access for Exchange 2000. It is Baseline Widely available (since January 2018), but prefer fetch(); XHR survives in legacy code and for upload progress (xhr.upload.onprogress), which Fetch lacks. Never pass false to open() for a synchronous request: it freezes the page. Call open(), then send() a body such as new FormData(form); readyState 4 means done.<pre id="log" style="font: 15px monospace"></pre>
<script>
const log = (msg) => (document.getElementById('log').textContent += msg + '\n');
const data = new Blob(['{"city":"Kuching","temp":31}'], { type: 'application/json' });
const xhr = new XMLHttpRequest();
xhr.open('GET', URL.createObjectURL(data)); // asynchronous by default
xhr.responseType = 'json'; // parse the body for us
xhr.onreadystatechange = () => log(`readyState ${xhr.readyState}`);
xhr.onprogress = (e) => log(`progress ${e.loaded} of ${e.total} bytes`);
xhr.onload = () => log(`status ${xhr.status}: ${xhr.response.city} is ${xhr.response.temp} °C`);
xhr.send();
</script>
Figure 9.2: Browser output of Listing 9.29.2.2 AJAX with Fetch
fetch(resource, options) returns a promise for a Response. It is Baseline Widely available (since September 2019) and shares Request, Response and Headers with the Cache API (Section 9.3.4). Read a body once with text(), json(), blob() or bytes(), or clone() it first. The promise rejects only on a network failure, CORS refusal or abort; a 404 resolves, so check response.ok.async function getJSON(input, options = {}) {
const response = await fetch(input, { signal: AbortSignal.timeout(2000), ...options });
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
return response.json();
}
const request = new Request('/api/notes', {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ text: 'Buy milk' }),
priority: 'high', // 'high' | 'low' | 'auto'
});
console.log('created:', await getJSON(request));
const report = (err) => console.log(`${err.name}: ${err.message}`);
await getJSON('/api/missing').catch(report); // server answers 404
await getJSON('/api/slow').catch(report); // server needs 5 seconds
created: {id: 1, text: Buy milk}
Error: HTTP 404 Not Found
TimeoutError: signal timed out
9.2.3 Streams
Streams process data chunk by chunk. response.body is a ReadableStream of bytes; a WritableStream is a destination; a TransformStream sits between. pipeThrough() chains transforms, pipeTo() ends in a sink, and tee() splits a stream. Pipes apply backpressure: a full sink queue stops the source. Streams are Baseline Widely available (since December 2024), as is CompressionStream for gzip and deflate.<pre id="out" style="font: 15px monospace"></pre>
<script type="module">
const text = 'Streams deliver data in chunks. '.repeat(500);
const zipped = await new Response(
new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'))).bytes();
let chunks = 0, chars = 0;
await new Blob([zipped]).stream()
.pipeThrough(new DecompressionStream('gzip'))
.pipeThrough(new TextDecoderStream()) // bytes -> strings
.pipeThrough(new TransformStream({ transform: (s, ctl) => ctl.enqueue(s.toUpperCase()) }))
.pipeTo(new WritableStream({ write(chunk) { chunks++; chars += chunk.length; } }));
out.textContent = `${text.length} chars -> ${zipped.length} gzip bytes\n`
+ `unzipped: ${chars} chars in ${chunks} chunk(s)`;
</script>
Figure 9.3: Browser output of Listing 9.4To show a download progressively, loop on reader.read() until done;
for await over a stream is not yet Baseline.9.2.4 Beacon
navigator.sendBeacon(url, data) queues a small POST that survives the page closing. It returns true if queued (64 KiB limit, shared with keepalive fetches) and is Baseline Widely available (since October 2020). Send analytics on visibilitychange, not unload, which mobile browsers often skip and which blocks the back/forward cache (Section 9.10).const startedAt = performance.now();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
const body = JSON.stringify({ event: 'leave', ms: Math.round(performance.now() - startedAt) });
navigator.sendBeacon('/beacon', new Blob([body], { type: 'application/json' }));
});
9.2.5 WebRTC: Data Channel
WebRTC (Baseline Widely available since July 2022) links browsers directly. Peers first swap an SDP offer, an SDP answer and ICE candidates (possible addresses) over a signaling channel you build, such as a WebSocket. STUN reveals each peer's public address; if a firewall blocks the direct path, a TURN server such as coturn relays traffic.
Figure 9.4: WebRTC signaling, NAT traversal and the direct peer
connectionAn RTCDataChannel works like a WebSocket between peers. It is
reliable and ordered unless you pass { ordered: false, maxRetransmits: 0
}. Here two peers in one page signal by direct calls:const alice = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const bob = new RTCPeerConnection();
alice.onicecandidate = ({ candidate }) => candidate && bob.addIceCandidate(candidate);
bob.onicecandidate = ({ candidate }) => candidate && alice.addIceCandidate(candidate);
const chat = alice.createDataChannel('chat');
chat.onopen = () => chat.send('Hello, Bob!');
bob.ondatachannel = ({ channel }) =>
(channel.onmessage = (e) => console.log(`Bob got "${e.data}" via ${channel.label}`));
await alice.setLocalDescription(); // creates the offer
await bob.setRemoteDescription(alice.localDescription);
await bob.setLocalDescription(); // creates the answer
await alice.setRemoteDescription(bob.localDescription);
9.2.6 WebRTC: Video Channel
Media shares the connection. Capture a MediaStream with getUserMedia() (Section 9.6.1) or getDisplayMedia(), add its tracks to a peer such as alice from Section 9.2.5 (this fires negotiationneeded, so offer and answer run again), and show remote streams from the track event.const local = await navigator.mediaDevices.getUserMedia({ video: { width: 1280 }, audio: true });
document.querySelector('#me').srcObject = local; // <video id="me" autoplay muted playsinline>
for (const track of local.getTracks()) alice.addTrack(track, local);
bob.ontrack = ({ streams: [remote] }) => {
document.querySelector('#them').srcObject = remote; // <video id="them" autoplay playsinline>
};
9.2.7 WebRTC: Broadcasting with Red5
For many viewers, each browser sends one stream to a media server. A Selective Forwarding Unit (SFU) forwards packets without decoding, choosing a lower simulcast layer for weak connections; an MCU decodes and mixes, at far higher CPU cost. WHIP (RFC 9725, March 2025) standardizes publishing as one HTTP POST of an SDP offer; the WHEP draft covers playback.9.2.8 WebSocket and WebSocketStream
A WebSocket (RFC 6455, 2011) is a persistent two-way connection: after an HTTP Upgrade: websocket request and a 101 Switching Protocols reply, either side sends text or binary messages at any time. It is Baseline Widely available (since January 2018). The client below talks to a Node echo server built with the ws package: socket.on('message', (m) => socket.send('echo: ' + m)).const socket = new WebSocket('ws://localhost:8093/'); // use wss:// in production
socket.onopen = () => socket.send('hello');
socket.onmessage = ({ data }) => {
console.log('message:', data);
socket.close(1000, 'done');
};
socket.onclose = ({ code, wasClean }) => console.log('closed', code, wasClean);
message: echo: hello
closed 1000 true
9.2.9 Server-Sent Events
Server-Sent Events (SSE) stream text from server to browser in one long text/event-stream response. Each message is field: value lines ended by a blank line, such as id: 3, event: price, data: {"n":3}. EventSource parses them and reconnects automatically, sending Last-Event-ID so the server can resume. SSE is Baseline Widely available (since July 2022). This Node server ends its response after every three events:const source = new EventSource('/events');
source.addEventListener('price', (e) => {
console.log(`id ${e.lastEventId}: ${e.data}`);
if (e.lastEventId === '4') source.close();
});
source.onerror = () => console.log('stream ended, reconnecting...');
id 1: {"n":1}
id 2: {"n":2}
id 3: {"n":3}
stream ended, reconnecting...
id 4: {"n":4}
9.2.10 Push Notifications
The Push API reaches users even when your site is closed. The page gets a subscription (an endpoint on the browser vendor's push service plus keys) from its service worker registration. Your server signs with a VAPID key pair and POSTs an encrypted payload to that endpoint; the browser wakes the service worker, which must show a notification (Section 9.4.5). Push is Baseline Widely available (since September 2025); on iOS 16.4+ only Home Screen web apps get it. It needs user permission, so it was not run headless.// page.js, in a click handler
const reg = await navigator.serviceWorker.register('/sw.js');
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: KEY });
await fetch('/subscribe', { method: 'POST', body: JSON.stringify(sub) });
// sw.js
self.onpush = (e) => e.waitUntil(registration.showNotification('Shipped', { body: e.data.text() }));
// server.js: npm install web-push; keys from npx web-push generate-vapid-keys
webpush.setVapidDetails('mailto:ops@example.com', KEY, PRIVATE_KEY);
await webpush.sendNotification(savedSub, 'Your parcel is on its way');
9.2.11 URL and Network Information
URL (Baseline Widely available since January 2018) parses hostname, port, pathname, search and hash, resolves relative references, and exposes the query as URLSearchParams, which encodes values for you. URL.canParse() and URL.parse() (returns null instead of throwing) validate input, and URLPattern (Newly available since September 2025) matches routes.<pre id="out" style="font: 15px monospace"></pre>
<script>
const u = new URL('https://shop.example:8080/watch?name=G%20Shock#specs');
u.searchParams.set('color', 'black & white');
out.textContent = [
`${u.port} | ${u.searchParams.get('name')} | ${u.search}`,
new URL('../cats', 'https://example.com/pets/dogs').href,
`${URL.canParse('not a url')} ${URL.parse('/relative')}`,
new URLPattern({ pathname: '/books/:id' }).exec('https://x.dev/books/42').pathname.groups.id,
`effectiveType ${navigator.connection?.effectiveType}, rtt ${navigator.connection?.rtt} ms`,
].join('\n');
</script>
Figure 9.5: Browser output of Listing 9.11The Network Information API (navigator.connection) estimates
effectiveType, rtt, downlink and saveData, but only Chromium exposes it:
use it as a hint, such as lighter images, never as a requirement.9.2.12 WebTransport
WebTransport runs a client-server session over HTTP/3 (QUIC over UDP). Its many streams are independent, so a lost packet stalls only its own stream, and unreliable datagrams suit data that is useless once late, such as game positions. It became Baseline Newly available in March 2026 with Safari 26.4. It needs an HTTP/3 server such as Go's webtransport-go or Python's aioquic (not run here).const wt = new WebTransport('https://game.example:4433/play');
await wt.ready;
const stream = await wt.createBidirectionalStream(); // reliable and ordered
await stream.writable.getWriter().write(new TextEncoder().encode('join room 7'));
const positions = wt.datagrams.writable.getWriter(); // unreliable, unordered
await positions.write(new Float32Array([12.5, 3.25]));
9.2.13 Background Fetch
A fetch() dies with its page. Background Fetch instead hands a list of requests to the browser, which downloads them behind a user-visible progress notification that survives a closed tab or a browser restart, then wakes the service worker with backgroundfetchsuccess so the responses can be cached. That suits a podcast episode or a level pack, where Background Sync (Section 9.4.6) only retries small deferred posts. abort() cancels a running fetch.const reg = await navigator.serviceWorker.ready; // page
const bg = await reg.backgroundFetch.fetch('ep-5', ['ep-5.mp3', 'ep-5-art.png'],
{ title: 'Episode 5', downloadTotal: 3000 }); // shown in the browser download UI
bg.onprogress = () => console.log(`${bg.downloaded}/${bg.downloadTotal} ${bg.result || '-'}`);
self.addEventListener('backgroundfetchsuccess', (e) => e.waitUntil((async () => { // sw.js
const cache = await caches.open('episodes'); // the only chance to keep the bytes
for (const r of await e.registration.matchAll())
await cache.put(r.request, await r.responseReady);
})()));
2000/3000 -
3000/3000 -
3000/3000 success
9.2.14 URL Pattern Matching
URLPattern (Section 9.2.11) compiles router syntax into a matcher that covers every URL component â protocol, hostname, port, pathname, search, hash â so one object checks host and path together. :name is a named group, :name(\d+) constrains it with a regular expression, * is a wildcard and {...}? an optional group; test() returns a boolean, exec() a groups map per component or null.<pre id="out" style="font: 14px monospace; margin: 4px"></pre>
<script>
const routes = [['/books/:id(\\d+)', 'book'], ['/books/:id/reviews{/:page}?', 'reviews'],
['/assets/*', 'asset']].map(([p, name]) => [new URLPattern({ pathname: p }), name]);
const route = (url) => {
const hit = routes.find(([p]) => p.test(url));
return hit ? `${hit[1]} ${JSON.stringify(hit[0].exec(url).pathname.groups)}` : 'no route';
};
out.textContent = ['/books/42', '/books/42/reviews/3', '/books/x', '/assets/img/a.png']
.map((p) => `${p.padEnd(19)} -> ${route('https://shop.dev' + p)}`).join('\n');
</script>
Figure 9.6: Browser output of Listing 9.14The hand-written first route, /^\/books\/(\d+)\/?$/, escapes every
slash, numbers its captures and ignores the host. Feed
event.destination.url from the Navigation API (Section 9.3.5) to the
same table and you have a router, in workers too. Baseline Newly
available since September 2025 (Chrome 95, Firefox 142, Safari 26);
older browsers need urlpattern-polyfill
(https://github.com/kenchris/urlpattern-polyfill).9.3 Storage
Every origin gets several places to keep data, and they differ in size, speed and lifetime. Cookies travel with every request, Web Storage is a small synchronous string map, IndexedDB holds structured records, the Cache API holds HTTP responses, and the origin private file system (OPFS) offers fast files. These last three share one per-origin quota (Section 9.3.8), and the DevTools Application panel (Section 1.3.4) lets you inspect and clear all of them. Listings are module scripts (top-level await) run on http://localhost in Chrome 152.9.3.1 Cookies
A cookie is a small name-value pair (usually at most 4 KB) sent with matching requests in the Cookie header. Servers set cookies with Set-Cookie; scripts use the quirky document.cookie accessor. Attributes set scope and lifetime: Expires or Max-Age (neither means a session cookie), Domain, Path, Secure, HttpOnly (hidden from scripts). SameSite=Strict limits it to same-site requests, Lax adds top-level GET navigations from other sites, and None (which requires Secure) sends it everywhere. Chrome treats a cookie without the attribute as Lax; Firefox and Safari do not by default.CHIPS (Cookies Having Independent Partitioned State) keeps embeds working without third-party cookies. A Partitioned cookie is also keyed by the top-level site, so a widget embedded on two shops gets two jars and cannot track users across them: Set-Cookie: __Host-chat=a1; Secure; Path=/; SameSite=None; Partitioned (Chrome 114, Firefox 141, Safari 26.2). Section 2.8 covers cookie security.The Cookie Store API replaces string parsing with promises, fires change events and works in service workers. MDN marks it Baseline 2025 (Chrome 87, Firefox 140, Safari 18.4), though only Chromium returns metadata such as path and expires.document.cookie = 'theme=dark; max-age=86400; path=/; samesite=lax'; // adds ONE cookie
document.cookie = `note=${encodeURIComponent('a;b=c')}`; // encode ; = and spaces
console.log(document.cookie); // only "name=value; ..."
cookieStore.onchange = (e) => console.log('change:', [...e.changed, ...e.deleted][0].name);
await cookieStore.set({ name: 'cart', value: '3', expires: Date.now() + 7 * 864e5 });
const cart = await cookieStore.get('cart');
console.log(cart.value, cart.sameSite); // default is strict
await cookieStore.delete('theme');
theme=dark; note=a%3Bb%3Dc
change: cart
3 strict
change: theme
9.3.2 Local Storage and Session Storage
Web Storage gives each origin two string maps that never travel to the server. localStorage survives restarts; sessionStorage belongs to one tab, survives reloads and is cleared when the tab closes. Both offer setItem(), getItem() (null if missing), removeItem(), clear(), key(i) and length, are Baseline Widely available, and hold 5 MiB each before setItem() throws QuotaExceededError.localStorage.setItem('prefs', JSON.stringify({ theme: 'dark', fontSize: 18 }));
sessionStorage.setItem('draft', 'Dear Ann,'); // gone when this tab closes
localStorage.visits = 5; // stored as the string "5"
console.log(localStorage.visits + 1, JSON.parse(localStorage.prefs).fontSize + 2);
try {
localStorage.setItem('big', 'x'.repeat(6 * 1024 ** 2)); // over the 5 MiB limit
} catch (err) {
console.log(err.name);
}
51 20
QuotaExceededError
9.3.3 IndexedDB
IndexedDB is a transactional database in every browser (Baseline Widely available). Object stores, its tables, hold any structured-cloneable value, Blobs included. Keys come from a keyPath property or autoIncrement; indexes on other properties support lookups and IDBKeyRange queries. Stores and indexes are created only in upgradeneeded, fired when the database is opened with a higher version. All work runs in readonly or readwrite transactions that commit once no requests are pending.The raw API reports results through request events; Jake Archibald's idb (https://github.com/jakearchibald/idb) (8.0.3, ISC license, npm install idb) wraps each request in a promise and changes little else:import { openDB } from 'https://cdn.jsdelivr.net/npm/idb@8/+esm';
const db = await openDB('school', 1, {
upgrade: (db) => db.createObjectStore('students', { keyPath: 'id' }).createIndex('clss', 'clss'),
});
const tx = db.transaction('students', 'readwrite');
await Promise.all([
tx.store.put({ id: 'H423498', name: 'Mike', clss: '5D', age: 11 }),
tx.store.put({ id: 'A213132', name: 'Bill', clss: '4H', age: 10 }),
tx.store.put({ id: 'H234324', name: 'Paul', clss: '3A', age: 7 }),
tx.done, // resolves when the transaction commits
]);
const range = IDBKeyRange.bound('1A', '4H'); // classes 1A..4H, inclusive
const kids = await db.getAllFromIndex('students', 'clss', range);
console.log(kids.map((s) => `${s.name} ${s.clss}`).join(', '));
9.3.4 The Cache API
The Cache API stores Request/Response pairs in named caches under the global caches. Usable from windows as well as service workers, it is Baseline Widely available (since April 2018) and needs a secure context. It ignores Cache-Control: entries never expire until your code deletes them. cache.add(url) fetches and stores in one step and rejects on a non-2xx status, while cache.put(request, response) stores any response you hand it. The listing builds a cache-first loader on Fetch (Section 9.2.2):async function cacheFirst(url, cacheName = 'api-v1') {
const cache = await caches.open(cacheName);
const hit = await cache.match(url);
if (hit) return ['cache', await hit.json()];
const response = await fetch(url);
if (response.ok) await cache.put(url, response.clone()); // a body is readable only once
return ['network', await response.json()];
}
for (const attempt of [1, 2]) {
const [from, products] = await cacheFirst('/data/products.json');
console.log(attempt, from, products.length);
}
1 network 3
2 cache 3
9.3.5 History pushState and the Navigation API
history.pushState(state, '', url) and replaceState() change the URL without a page load, and popstate reports back and forward (Section 8.2.7), but routers must still intercept every link click and form submission. The Navigation API, window.navigation, replaces that plumbing: one navigate event fires for link clicks, form submissions, navigation.navigate(), history.pushState() and the back button, and event.intercept() turns the navigation into a same-document update. It became Baseline Newly available in January 2026 (Chrome 102, Safari 26.2, Firefox 147).navigation.addEventListener('navigate', (event) => {
const url = new URL(event.destination.url);
if (!event.canIntercept || event.hashChange || !url.pathname.startsWith('/app/')) return;
event.intercept({
async handler() { // runs instead of a full page load
document.querySelector('main').textContent = `Rendered ${url.pathname}`;
console.log(event.navigationType, url.pathname);
},
});
});
await navigation.navigate('/app/products').finished; // <a> clicks and forms land here too
await navigation.navigate('/app/cart', { state: { items: 3 } }).finished;
await navigation.back().finished;
history.pushState(null, '', '/app/help'); // the old API is intercepted too
push /app/products
push /app/cart
traverse /app/products
push /app/help
9.3.6 FileReader and Blob
A Blob is an immutable chunk of bytes with a MIME type and a size; blob.slice() cuts pieces without copying. A File is a Blob with a name and lastModified, from <input type="file">, drag and drop (dataTransfer.files) or the pickers in Section 9.3.7. Read a blob with the promise methods text(), arrayBuffer(), bytes() and stream(), or with the older event-based FileReader, which still offers readAsDataURL(). URL.createObjectURL(blob) makes a blob: URL for src or href until URL.revokeObjectURL(). The File API is Baseline Widely available; bytes() came last (Chrome 144). The demo simulates a user's choice:<input type="file" id="pick"><pre id="out"></pre>
<script type="module">
const log = (...parts) => (out.textContent += parts.join(' ') + '\n');
pick.addEventListener('change', async () => {
const [file] = pick.files; // a File is a Blob with a name
log(file.name, file.type, file.size, 'bytes |', await file.slice(7).text());
log('bytes():', (await file.bytes()).slice(0, 5).join(' '));
const reader = new FileReader(); // older event-based API
reader.onload = () => log('readAsDataURL:', reader.result);
reader.readAsDataURL(file);
});
const picked = new DataTransfer(); // simulate the user's choice
picked.items.add(new File(['Hello, Blob!'], 'hello.txt', { type: 'text/plain' }));
pick.files = picked.files;
pick.dispatchEvent(new Event('change'));
</script>
Figure 9.7: Browser output of Listing 9.20Check size before reading. type is guessed from the file extension,
so validate uploads again on the server.9.3.7 File System Access API and the Origin Private File System
The File System API works with FileSystemFileHandle and FileSystemDirectoryHandle objects. The File System Access API gets handles to the user's files through showOpenFilePicker(), showSaveFilePicker() and showDirectoryPicker(), so an editor can save back to the file it opened. It is Chromium-only (Chrome 86), needs a user gesture and throws AbortError on cancel, so it cannot run headless; elsewhere use <input type="file"> and downloads.The origin private file system (OPFS) is Baseline Widely available: a sandboxed folder per origin, invisible to the user, with no prompts and the same quota as IndexedDB. In a dedicated worker, createSyncAccessHandle() gives fast in-place reads and writes, which is how SQLite compiled to WebAssembly keeps a database on the web.const root = await navigator.storage.getDirectory(); // this origin's private root
const dir = await root.getDirectoryHandle('notes', { create: true });
const handle = await dir.getFileHandle('todo.txt', { create: true });
const writable = await handle.createWritable(); // writes go to a swap file...
await writable.write('1. Buy milk\n');
await writable.close(); // ...committed atomically here
const file = await handle.getFile(); // a File (a Blob) snapshot
console.log(file.size, JSON.stringify(await file.text()), (await root.resolve(handle)).join('/'));
9.3.8 Storage Information (Quotas and Persistence)
IndexedDB, the Cache API and OPFS share one quota per origin, exposed by navigator.storage (StorageManager, Baseline Widely available). estimate() returns approximate usage and quota in bytes, plus usageDetails in Chromium. Data is best-effort: under disk pressure the browser evicts whole origins, least recently used first, and Safari also clears script-written data of sites unvisited for seven days. persist() asks for exemption from that eviction; Firefox prompts, while Chrome and Safari decide silently from the user's interaction with the site (a fresh headless profile is refused):await (await caches.open('big')).put('/zeros', new Response(new Uint8Array(3 * 1024 ** 2)));
const { usage, quota, usageDetails } = await navigator.storage.estimate();
const mib = (bytes) => `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
console.log(mib(usage), 'of', mib(quota), JSON.stringify(usageDetails)); // details: Chromium
console.log(await navigator.storage.persisted(), await navigator.storage.persist());
3.0 MiB of 10243.0 MiB {"caches":3145815}
false false
9.3.9 Storage Access in Embedded Contexts
Block third-party cookies and a cross-site iframe â a chat widget, a checkout â reads an empty document.cookie. The Storage Access API is the way back in: document.hasStorageAccess() reports whether the frame holds its unpartitioned cookies, document.requestStorageAccess() asks for them. It needs transient activation, a secure context, SameSite=None; Secure cookies and a recent first-party visit to the embedded site; sandboxed iframes also need allow-storage-access-by-user-activation. Baseline Widely available since June 2026 (Safari 11.1, Firefox 65, Chrome 119).const { state } = await navigator.permissions.query({ name: 'storage-access' });
console.log('cookie:', JSON.stringify(document.cookie), '| permission:', state);
button.onclick = async () => { // a genuine click is mandatory
if (await document.hasStorageAccess()) return;
try { await document.requestStorageAccess(); } // may show a permission prompt
catch (err) { return console.log('denied:', err.name); }
console.log('granted; cookie now', JSON.stringify(document.cookie));
};
cookie: "" | permission: prompt
denied: NotAllowedError
9.4 Tasks and Workers
A page runs JavaScript, layout and input handling on one main thread, so a 200 ms computation freezes the page for 200 ms. Either move the work to a worker thread, or split it into tasks the browser schedules around input (Sections 9.4.9-9.4.12). Workers have no DOM but keep fetch(), timers, IndexedDB and caches; they exchange copies via postMessage() or share memory through SharedArrayBuffer (Sections 6.14.3-6.14.4).9.4.1 Dedicated Web Workers
A dedicated worker is a background thread owned by its page, for parsing, image processing or any loop that would block input. With { type: 'module' } (Baseline since 2023) the worker can use import; classic workers call importScripts().// fib-worker.js
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
self.onmessage = (e) => postMessage(fib(e.data));
// page: <script type="module">
const worker = new Worker('fib-worker.js', { type: 'module' });
worker.onmessage = (e) => { console.log('fib(40) =', e.data); worker.terminate(); };
worker.postMessage(40);
console.log('the page stays responsive meanwhile');
the page stays responsive meanwhile
fib(40) = 102334155
9.4.2 Shared Workers
A shared worker is one thread that every same-origin page, iframe or worker connects to by constructing SharedWorker with the same URL, for example to share one WebSocket between tabs. It became Baseline Newly available in May 2026 with Chrome for Android 148.// counter.js: each page arrives as a 'connect' event carrying a MessagePort
let connections = 0;
onconnect = ({ ports: [port] }) => {
const id = ++connections;
port.onmessage = ({ data }) => port.postMessage(`${data} is client #${id} of ${connections}`);
};
// page script
const { port } = new SharedWorker('counter.js');
port.onmessage = (e) => console.log(e.data); // assigning onmessage calls port.start()
port.postMessage(location.hash.slice(1));
Tab-A is client #1 of 1
Tab-B is client #2 of 2
9.4.3 Service Workers: Messaging Channel
A service worker is an event-driven worker that sits between the network and the pages in its origin and URL scope. It needs HTTPS (or localhost), outlives tabs and is stopped when idle, so keep no state in its variables. Inspect it in the DevTools Application panel (Section 1.3.4).
Figure 9.8: The service worker lifecycle// sw.js
self.addEventListener('install', () => self.skipWaiting()); // don't wait for old tabs
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
let received = 0; // lost whenever the browser stops the idle worker
self.addEventListener('message', (e) => {
(e.ports[0] ?? e.source).postMessage(`${e.data} #${++received}`); // port, or the sender
});
// page: <script type="module">
navigator.serviceWorker.onmessage = (e) => console.log('via client:', e.data);
const reg = await navigator.serviceWorker.register('sw.js');
await navigator.serviceWorker.ready; // resolves once a worker is active
reg.active.postMessage('ping');
const { port1, port2 } = new MessageChannel(); // a private reply channel
port1.onmessage = (e) => console.log('via port:', e.data);
reg.active.postMessage('question', [port2]); // transfer port2 to the worker
via port: question #2
via client: ping #1
9.4.4 Service Workers: Offline Support
Offline support pairs the fetch event with the Cache API (Section 9.3.4): install precaches the app shell, and fetch answers from the cache, then the network, then an offline page. waitUntil() keeps the worker alive until a promise settles; respondWith() replaces the browser's own request.const CACHE = 'shell-v2'; // new name per release; delete old caches on 'activate'
self.addEventListener('install', (e) => e.waitUntil(
caches.open(CACHE).then((c) => c.addAll(['./', 'index.html', 'app.css', 'offline.html']))));
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
e.respondWith(caches.match(e.request).then((hit) => hit ?? fetch(e.request)
.catch(() => caches.match('offline.html'))));
});
9.4.5 Service Workers: Notifications
registration.showNotification(title, { body, icon, tag, data }) shows a notification whose clicks reach the worker. Web Push wakes the worker with no tab open: the page calls pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) with your VAPID public key and stores the subscription on your server, which sends messages with a library such as web-push (webpush.sendNotification(subscription, payload)). Both are Baseline Widely available; on iOS 16.4+ push works only in Home Screen web apps.self.addEventListener('push', (e) => {
const msg = e.data?.json() ?? { title: 'New update' };
e.waitUntil(self.registration.showNotification(msg.title, { body: msg.body, data: msg }));
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
e.waitUntil(self.clients.openWindow(e.notification.data.url ?? '/'));
});
9.4.6 Service Workers: Background Sync and PeriodicSyncManager
Background Sync finishes queued work, such as an offline message, once the connection returns, even after the tab closes: registration.sync.register(tag) fires a sync event, retried later if its waitUntil() promise rejects. Periodic Background Sync fires periodicsync no more often than minInterval. Both are Limited availability (Chrome and Edge only), and Chrome grants periodic sync only to installed web apps.const reg = await navigator.serviceWorker.ready; // page
await reg.sync?.register('send-outbox'); // queued data waits in IndexedDB
await reg.periodicSync?.register('refresh-news', { minInterval: 12 * 3600 * 1000 })
.catch(() => {}); // rejects unless the app is installed
self.addEventListener('sync', (e) => { // sw.js
if (e.tag === 'send-outbox') e.waitUntil(sendQueuedMessages()); // your upload function
});
9.4.7 Service Workers: NavigationPreloadManager
When a stopped worker handles a navigation, the network request waits for the worker to boot. Navigation preload starts the request in parallel and hands it over as event.preloadResponse, with a Service-Worker-Navigation-Preload header the server can detect. It works in Chrome 59+, Firefox 99+ and Safari 15.4+.self.addEventListener('activate', (e) => {
e.waitUntil(self.registration.navigationPreload?.enable());
});
self.addEventListener('fetch', (e) => {
if (e.request.mode !== 'navigate') return;
e.respondWith((async () => (await e.preloadResponse) ?? fetch(e.request))());
});
9.4.8 Broadcast Channel
BroadcastChannel is a publish/subscribe bus for one origin: every tab, iframe and worker that opens a channel with the same name receives each message, except the sender. It needs no server and no port passing, and it is Baseline Widely available. A classic use is logging out every tab at once.const channel = new BroadcastChannel('auth');
channel.onmessage = ({ data }) => console.log(`${location.hash} got`, JSON.stringify(data));
const logout = () => channel.postMessage({ type: 'logout', at: new Date().toISOString() });
if (location.hash === '#tab-A') setTimeout(logout, 500);
#tab-C got {"type":"logout","at":"2026-09-15T16:43:13.362Z"}
#tab-B got {"type":"logout","at":"2026-09-15T16:43:13.362Z"}
9.4.9 requestIdleCallback()
requestIdleCallback(callback, { timeout }) runs low-priority work such as analytics or prefetching when the browser is idle (basics in Section 8.2.11). The deadline's timeRemaining() never exceeds 50 ms, and didTimeout is true when timeout forced the call. It is Limited availability: Chrome 47+ and Firefox 55+, but Safari only in preview builds.const onIdle = window.requestIdleCallback ??
((cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 10 }), 1));
const queue = Array.from({ length: 4000 }, (_, i) => i);
let periods = 0;
onIdle(function drain(deadline) {
periods++;
while (queue.length && (deadline.timeRemaining() > 1 || deadline.didTimeout)) {
for (let i = 0, n = queue.shift(); i < 2e5; i++) Math.sqrt(i + n); // ~0.1 ms each
}
if (queue.length) onIdle(drain, { timeout: 2000 });
else console.log(`4000 items in ${periods} idle periods`);
}, { timeout: 2000 });
9.4.10 Long Tasks and Long Animation Frames
A longtask entry reports a main-thread task over 50 ms but not its cause. Long Animation Frames (long-animation-frame entries) report frames delayed beyond 50 ms, with a scripts array naming the invoker, function and sourceURL. Both are Limited availability: Chromium only, long tasks since Chrome 58 and LoAF since Chrome 123 (March 2024).<button id="sort">Sort 300,000 rows</button>
<script>
new PerformanceObserver((list) => list.getEntries().forEach((frame) => {
console.log(`frame ${Math.round(frame.duration)} ms, blocking ${frame.blockingDuration} ms`);
for (const s of frame.scripts) console.log(` ${s.invoker} -> ${s.sourceFunctionName}`);
})).observe({ type: 'long-animation-frame', buffered: true });
document.getElementById('sort').addEventListener('click', function sortRows() {
Array.from({ length: 300_000 }, Math.random).sort((a, b) => a - b);
});
</script>
frame 168 ms, blocking 117.3 ms
BUTTON#sort.onclick -> sortRows
9.4.11 Web Locks
navigator.locks is a mutex shared by all tabs and workers of an origin, Baseline Widely available. request(name, callback) waits for the lock and releases it when the callback's promise settles, so only one tab refreshes a token. Pass mode: 'shared' for concurrent readers, ifAvailable: true to receive null instead of waiting, or steal: true to take the lock by force.const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const save = (who) => navigator.locks.request('drafts-db', async (lock) => {
const { pending } = await navigator.locks.query();
console.log(`${who} holds ${lock.name} (${lock.mode}); ${pending.length} waiting`);
await wait(100); // held until this promise settles
});
save('A');
save('B');
navigator.locks.request('drafts-db', { ifAvailable: true }, (lock) => console.log('C:', lock));
C: null
A holds drafts-db (exclusive); 1 waiting
B holds drafts-db (exclusive); 0 waiting
9.4.12 Prioritized Task Scheduling
scheduler.postTask(fn, { priority, signal }) queues work as 'user-blocking', 'user-visible' (default) or 'background'; a TaskController can abort it or change its priority. await scheduler.yield() lets input and rendering run mid-job, then resumes ahead of same-priority tasks, whereas setTimeout() resumes at the back of the queue. Both are Limited availability: postTask() since Chrome 94, yield() since Chrome 129, both in Firefox 142 (August 2025), not in Safari.scheduler.postTask(() => console.log('background task'), { priority: 'background' });
scheduler.postTask(() => console.log('user-visible task'));
scheduler.postTask(() => console.log('user-blocking task'), { priority: 'user-blocking' });
const yieldToMain = () => globalThis.scheduler?.yield?.() ??
new Promise((resolve) => setTimeout(resolve)); // fallback for Safari
for (let chunk = 0, sum = 0; chunk < 20; chunk++) {
for (let i = 0; i < 3e6; i++) sum += i % 2; // a few ms of work
await yieldToMain(); // input can run here
}
console.log('20 chunks done');
user-blocking task
20 chunks done
user-visible task
background task
9.4.13 Progressive Web Apps
A progressive web app (PWA) is an installable website with its own window and launcher icon. Link a web app manifest with <link rel="manifest" href="app.webmanifest">. Chromium offers installation when it has name or short_name, start_url, a display other than browser, and 192 px and 512 px icons (Section 2.12.1), served over HTTPS or localhost; a service worker is optional. Safari 17 adds sites to the macOS Dock, iOS 16.4+ browsers add them to the Home Screen, and Firefox desktop cannot install PWAs.{
"name": "Trail Notes: Hiking Journal", "short_name": "Trail Notes",
"id": "./", "start_url": "./?source=pwa", "display": "standalone",
"theme_color": "#1565c0", "background_color": "#ffffff",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/maskable-512.png", "sizes": "512x512", "purpose": "any maskable" }
]
}
9.4.14 Channel Messaging
new MessageChannel() creates two entangled ports: whatever you postMessage() into one arrives at the other. A port is transferable, so you can hand one end to a worker, an iframe or a service worker (Section 9.4.3) and let the two ends talk privately afterwards. A BroadcastChannel (Section 9.4.8) shouts at every context on the origin and window.postMessage() drops everything into one shared inbox; a channel is point-to-point.// tally-worker.js
self.onmessage = ({ ports }) => {
const port = ports[0]; // the transferred end
let total = 0;
port.onmessage = ({ data }) => port.postMessage((total += data));
};
// page: <script type="module">
const worker = new Worker('tally-worker.js');
const { port1, port2 } = new MessageChannel();
worker.postMessage('here is your end', [port2]); // transferred, not cloned
port1.onmessage = ({ data }) => console.log('running total', data);
for (const n of [5, 10, 20]) port1.postMessage(n);
running total 5
running total 15
running total 35
9.4.15 Page Visibility and Page Lifecycle
document.visibilityState is "visible" or "hidden", and visibilitychange fires whenever it flips: another tab, a minimized window, a phone going to sleep. Pause loops, polls and camera tracks there, and save there too, because hidden is the last callback a mobile browser guarantees. Chromium adds two states: a hidden tab may be frozen to reclaim CPU (freeze, then resume), and a frozen tab may be discarded, after which the reload sets document.wasDiscarded.document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/autosave', draft.value); // never use unload for this
clearInterval(poll);
} else poll = setInterval(refresh, 30_000);
});
addEventListener('freeze', () => socket.close()); // Chromium: tab put to sleep
addEventListener('resume', () => connect());
if (document.wasDiscarded) restoreFrom(sessionStorage);
9.5 Interface
These APIs connect a page to the desktop around it. Most need a secure context and a user click (Section 9.1), so call them from event handlers and feature-detect first. Support notes follow webstatus.dev in September 2026.9.5.1 Drag and Drop
HTML drag and drop (Widely available) moves data, not elements. A draggable="true" element's dragstart handler stores strings in event.dataTransfer; a drop target must cancel dragover to accept a drop, and its drop handler reads the data back.<style>body { font: 15px system-ui; display: flex; gap: 8px; } [draggable] { background: #e3f2fd; }
div { width: 190px; min-height: 60px; border: 2px dashed #90a4ae; padding: 4px; }</style>
<div><b>To do</b><p draggable="true">Add tests</p><p draggable="true" id="bug">Fix bug #42</p></div>
<div id="done"><b>Done</b></div>
<script>
addEventListener('dragstart', (e) => e.dataTransfer.setData('text/plain', e.target.id));
done.ondragover = (e) => e.preventDefault(); // allow dropping here
done.ondrop = (e) => {
e.preventDefault(); // skip the default handling
done.append(document.getElementById(e.dataTransfer.getData('text/plain')));
};
const dataTransfer = new DataTransfer();
bug.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer }));
done.dispatchEvent(new DragEvent('drop', { cancelable: true, dataTransfer }));
</script>
Figure 9.9: Browser output of Listing 9.39Desktop files arrive in dataTransfer.files (Section 9.3.6), and
getData() stays empty until drop so pages cannot snoop on a passing
drag. Add Move buttons for keyboard users; SortableJS
(https://github.com/SortableJS/Sortable) (MIT) builds touch-ready
sortable lists.9.5.2 Clipboard
navigator.clipboard (Baseline since June 2024) replaces document.execCommand('copy'). writeText() and readText() handle text; write() and read() take ClipboardItems holding one Blob per MIME type, so one copy can offer HTML to a word processor and plain text to a terminal.copyButton.onclick = async () => {
await navigator.clipboard.writeText('Larry Tesler 1945-2020');
const html = new Blob(['<b>Cut</b>, copy, paste'], { type: 'text/html' });
const text = new Blob(['Cut, copy, paste'], { type: 'text/plain' });
await navigator.clipboard.write([new ClipboardItem({ 'text/html': html, 'text/plain': text })]);
const [item] = await navigator.clipboard.read();
console.log(item.types.join(), ClipboardItem.supports('image/svg+xml'));
};
9.5.3 Screen Display
screen reports width, height, availHeight and orientation. Any element can fill the display with requestFullscreen() after a click; :fullscreen styles it and Escape or document.exitFullscreen() leaves. screen.orientation is Widely available, but lock() works only in full screen on Chrome for Android and Firefox 144+.console.log(`${screen.width}x${screen.height} ${screen.orientation.type}`);
goButton.onclick = async () => {
await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
console.log('fullscreen:', document.fullscreenElement.tagName);
await screen.orientation.lock('landscape').catch((err) => console.log('lock:', err.name));
await document.exitFullscreen();
};
1280x720 landscape-primary
fullscreen: HTML
lock: NotSupportedError
9.5.4 Navigator
navigator describes the browser (language, onLine, hardwareConcurrency). Chrome 107 reduced the userAgent string to fixed values; Chromium's navigator.userAgentData offers brands and platform, with finer details on request.const ua = navigator.userAgentData;
console.log(ua.brands.map((b) => `${b.brand} ${b.version}`).join(', '), ua.platform);
const hi = await ua.getHighEntropyValues(['architecture', 'fullVersionList']);
console.log(hi.architecture, hi.fullVersionList.at(-1).version);
shareButton.onclick = async () => {
const data = { title: 'Order #1042', url: location.href };
if (navigator.canShare?.(data)) await navigator.share(data).catch(() => {}); // AbortError: cancel
navigator.setAppBadge?.(3); // "3" on the installed app's icon
};
Chromium 152, Not?A_Brand 24, Google Chrome 152 Windows
x86 152.0.7977.78
9.5.5 TextEncoder and TextDecoder
TextEncoder turns strings into UTF-8 bytes; TextDecoder decodes bytes in any WHATWG encoding (both Widely available). Beyond the basics in Section 8.6.6, files and sockets bring legacy encodings and split characters.const cyrillic = new Uint8Array([207, 240, 232, 226, 229, 242, 33]);
console.log(new TextDecoder('windows-1251').decode(cyrillic));
console.log(new TextEncoder().encodeInto('añbâ¬', new Uint8Array(4))); // no room for "â¬"
const decoder = new TextDecoder(); // "â¬" is E2 82 AC, split in two
console.log(decoder.decode(new Uint8Array([0xe2, 0x82]), { stream: true }) +
decoder.decode(new Uint8Array([0xac])));
ÐÑивеÑ!
{read: 3, written: 4}
â¬
9.5.6 XPath and XSLT
XPath selects nodes and computes values: document.evaluate(expression, contextNode, resolver, resultType) (Widely available) returns an XPathResult. XSLT 1.0 stylesheets transform XML through XSLTProcessor.const parse = (s) => new DOMParser().parseFromString(s, 'application/xml');
const xml = parse('<menu><dish price="9">Satay</dish><dish price="12">Curry</dish></menu>');
console.log(xml.evaluate('sum(//dish/@price)', xml, null, XPathResult.NUMBER_TYPE).numberValue);
const xsl = parse(`<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/menu"><ul><xsl:for-each select="dish">
<li><xsl:value-of select="."/> $<xsl:value-of select="@price"/></li>
</xsl:for-each></ul></xsl:template></xsl:stylesheet>`);
const processor = new XSLTProcessor();
processor.importStylesheet(xsl);
console.log(processor.transformToFragment(xml, document).firstChild.textContent.trim());
21
XSLTProcessor and XSLT Processing Instructions have been deprecated by all browsers. These
features will
be removed from this browser soon. See https://chromestatus.com/feature/4709671889534976.
Satay $9
Curry $12
9.5.7 MathML
MathML marks up formulas: <mi> identifiers, <mn> numbers, <mo> operators, plus <mfrac>, <msup> and <msqrt>. Chrome 109 added MathML Core in January 2023, so it is Widely available without MathJax.<math display="block" style="font-size: 26px"><mi>x</mi><mo>=</mo><mfrac>
<mrow><mo>â</mo><mi>b</mi><mo>±</mo><msqrt><msup><mi>b</mi><mn>2</mn></msup>
<mo>â</mo><mn>4</mn><mi>a</mi><mi>c</mi></msqrt></mrow>
<mrow><mn>2</mn><mi>a</mi></mrow></mfrac></math>
Figure 9.10: Browser output of Listing 9.45MathML Core dropped <mfenced>; write brackets as
<mo>(</mo>. To avoid hand-typing, convert LaTeX with Temml
(https://github.com/ronkok/Temml) or KaTeX
(https://github.com/KaTeX/KaTeX) (both MIT).9.5.8 Web Animations API
The Web Animations API (Widely available) exposes the engine behind CSS animations (Section 4.9). element.animate(keyframes, options) returns an Animation to pause(), reverse() or cancel(), seek with currentTime, and await through finished.<style>b { display: block; width: 40px; height: 24px; margin: 6px; }</style>
<b></b><b></b><b></b>
<script>
const keyframes = { transform: ['none', 'translateX(300px) rotate(90deg)'],
background: ['#1565c0', '#ef6c00'] };
document.querySelectorAll('b').forEach((box, i) => {
const anim = box.animate(keyframes, { duration: 1000, easing: 'ease-in-out', fill: 'both' });
anim.pause();
anim.currentTime = i * 500;
});
</script>
Figure 9.11: Browser output of Listing 9.46document.getAnimations() includes CSS animations, so one loop pauses
them all; commitStyles() keeps the final frame. Motion
(https://github.com/motiondivision/motion) (MIT) adds springs and
sequences on top.9.5.9 Local Notifications
The Notifications API shows system notifications. Request permission from a click (Firefox 72+ and Safari refuse other requests), then create a Notification. Headless Chrome denies permission, so try this in a real browser.notifyButton.onclick = async () => {
if (!('Notification' in window) || await Notification.requestPermission() !== 'granted') return;
const note = new Notification('Build finished', {
body: '42 tests passed', icon: '/icons/ci.png', tag: 'build', // same tag replaces the old one
});
note.onclick = () => { window.focus(); note.close(); };
};
9.5.10 View Transitions API
Section 4.9.5 styles view transitions; script controls them. document.startViewTransition() returns a ViewTransition whose ready and finished promises mark the animation's start and end. The types option labels a transition for :active-view-transition-type() in CSS, and skipTransition() skips the animation.const update = () => (page.textContent = 'Page 2');
const vt = document.startViewTransition({ update, types: ['forward'] });
console.log('forward:', vt.types.has('forward'), 'active:', document.activeViewTransition === vt);
await vt.ready; // snapshots taken
document.documentElement.animate({ transform: ['translateX(100%)', 'none'] },
{ duration: 300, pseudoElement: '::view-transition-new(root)' });
await vt.finished;
console.log(page.textContent, 'finished, active:', document.activeViewTransition);
forward: true active: true
Page 2 finished, active: null
9.5.11 Popover and Dialog from JavaScript
Section 2.6 declares popovers and dialogs in markup; script opens them when logic decides. showPopover(), hidePopover() and togglePopover() accept { source } (Chrome 137, Firefox 144, Safari 26), which ties the popover to its button for accessibility and anchoring. A <dialog> has show(), showModal(), close(value) and requestClose(value), which fires a cancelable cancel event first.<style>body { font: 15px system-ui; } #log { position: absolute; left: 240px; top: 0; }
[popover] { margin: 0; inset: auto; position-area: bottom span-right; }</style>
<button id="menuButton">Account â¾</button> <pre id="log"></pre>
<div id="menu" popover>Signed in as <b>lip</b><br><button>Sign out</button></div>
<script>
const say = (text) => (log.textContent += text + '\n');
menu.onbeforetoggle = (e) => say(`beforetoggle â ${e.newState}`);
menu.ontoggle = (e) => say(`toggle ${e.oldState} â ${e.newState}`);
menuButton.onclick = () => menu.togglePopover({ source: menuButton });
say(`togglePopover() returned ${menu.togglePopover({ source: menuButton })}`);
</script>
Figure 9.12: Browser output of Listing 9.49beforetoggle fires synchronously (cancel it to refuse opening) and
toggle afterward. Never set dialog.open = true: it skips the top layer,
focus handling and ::backdrop.9.5.12 Picture-in-Picture and Remote Playback
video.requestPictureInPicture() floats a video in a small always-on-top window that survives tab switches; its PictureInPictureWindow reports width and height and fires resize, so you can swap in a better source. Document Picture-in-Picture opens an empty same-origin window you fill with real DOM, so a whole custom player can move out of the page and back. The Remote Playback API adds casting: video.remote prompts for a Chromecast or AirPlay device and reports whether any is reachable.const video = document.querySelector('#player video');
video.remote.watchAvailability((yes) => { cast.hidden = !yes; console.log('device:', yes); });
cast.onclick = () => video.remote.prompt().catch((e) => console.log('prompt ->', e.name));
pip.onclick = async () => { // all three calls need a user gesture
const w = await video.requestPictureInPicture();
console.log('PiP window', `${w.width}x${w.height}`);
};
popOut.onclick = async () => {
const win = await documentPictureInPicture.requestWindow({ width: 320, height: 200 });
win.document.body.append(player); // move the live element, listeners and all
console.log('doc PiP opened; same origin:', win.opener === window);
};
device: false
PiP window 284x160
doc PiP opened; same origin: true
9.5.13 Text Fragments and Custom Highlights
A text fragment links to a phrase that has no id. Append #:~:text=[prefix-,]textStart[,textEnd][,-suffix] to any URL and the browser finds the first match, scrolls to it and highlights it; prefix- and -suffix are context words that are matched but not highlighted, so a phrase occurring twice can still be pinpointed. The :~: directive is stripped before scripts see the URL, and it runs only on user-initiated navigations of the top-level document. Feature-detect with document.fragmentDirective, restyle the match with ::target-text, opt a site out with Document-Policy: force-load-at-top.const enc = encodeURIComponent;
const textLink = (url, start, suffix) =>
`${url}#:~:text=${enc(start)}` + (suffix ? `,-${enc(suffix)}` : '');
console.log(textLink('http://localhost:8000/notes.html', 'renderer builds', 'a layout'));
// then, in the page Chrome 152 loaded from that link:
console.log(String(document.fragmentDirective), JSON.stringify(location.hash), Math.round(scrollY));
http://localhost:8000/notes.html#:~:text=renderer%20builds,-a%20layout
[object FragmentDirective] "" 820
9.6 Media
Beyond playing files (Sections 2.4.5 and 8.5.5), the browser can capture, record, stream, speak, listen, make music and encode video frame by frame.Many of these APIs revolve around the MediaStream: a bundle of audio and video MediaStreamTrack objects with a source (a camera, a screen, a WebRTC peer as in Section 9.2.6, or a Web Audio node) and consumers (a media element, MediaRecorder, RTCPeerConnection or AudioContext). Capture needs a secure context and permission. The examples ran in headless Chrome 152 with its fake camera and microphone.9.6.1 getUserMedia and Image Capture
navigator.mediaDevices.getUserMedia(constraints) asks for a camera and/or microphone and resolves to a MediaStream. Constraints such as width, frameRate, deviceId (from enumerateDevices()) or facingMode: 'environment' (the rear camera) take plain values or ideal, min, max and exact; a refusal rejects with NotAllowedError. The Image Capture API then takes stills from the video track.const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 1280 } });
video.srcObject = stream; // <video autoplay muted playsinline>
const [track] = stream.getVideoTracks();
const { width, height, frameRate } = track.getSettings(); // what you actually got
console.log(`${track.label}: ${width}x${height} @ ${frameRate} fps`);
const photo = await new ImageCapture(track).takePhoto(); // grabFrame() returns an ImageBitmap
console.log(`photo: ${photo.type}, ${photo.size} bytes`);
fake_device_0: 1280x720 @ 20 fps
photo: image/png, 5617 bytes
9.6.2 MediaStream Recording
MediaRecorder compresses any MediaStream (camera, screen, canvas captureStream(), WebRTC or Web Audio) into a file. Check formats with isTypeSupported(): Chrome 152 records WebM and MP4, and Safari added WebM in version 18.4.const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
const types = ['video/webm;codecs=vp9,opus', 'video/mp4'];
const mimeType = types.find((t) => MediaRecorder.isTypeSupported(t));
const recorder = new MediaRecorder(stream, { mimeType });
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = () => (player.src = URL.createObjectURL(new Blob(chunks, { type: mimeType })));
recorder.start(1000); // emit a chunk every second
setTimeout(() => recorder.stop(), 3000); // Chrome 152: 3 chunks, 193 KB of VP9
9.6.3 Screen Capture
navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: 'browser' }, audio: true }) returns a stream of the screen, window or tab the user picks in the browser's dialog; displaySurface only suggests a default. Call it from a click handler (otherwise it throws InvalidStateError); it works on desktop browsers only. Show, record or send the stream over WebRTC (Section 9.2.6), and handle the track's ended event, fired when the user clicks "Stop sharing". Audio capture, preferCurrentTab and CaptureController are Chromium-only. Set track.contentHint = 'text' when sharing slides or code to keep edges sharp.9.6.4 Media Capabilities
Where canPlayType() answers only "maybe" or "probably", navigator.mediaCapabilities.decodingInfo() reports whether an exact configuration is supported, smooth and powerEfficient (usually hardware-decoded), so a player can pick the rendition that saves battery. encodingInfo() does the same for recording and WebRTC.const codecs = { 'H.264': 'avc1.640028', VP9: 'vp09.00.40.08', AV1: 'av01.0.08M.08' };
for (const [name, codec] of Object.entries(codecs)) {
const { supported, smooth, powerEfficient } = await navigator.mediaCapabilities.decodingInfo({
type: 'media-source', // or 'file', 'webrtc'
video: { contentType: `video/mp4; codecs="${codec}"`, width: 1920, height: 1080,
bitrate: 5_000_000, framerate: 30 },
});
console.log(name.padEnd(5), JSON.stringify({ supported, smooth, powerEfficient }));
}
H.264 {"supported":true,"smooth":true,"powerEfficient":true}
VP9 {"supported":true,"smooth":true,"powerEfficient":true}
AV1 {"supported":true,"smooth":true,"powerEfficient":false}
9.6.5 Media Session
The Media Session API connects a player to controls outside the page: media keys, phone lock screens, Chrome's media hub and smartwatches. Set navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork }), register handlers such as setActionHandler('nexttrack', playNext), and call setPositionState({ duration, position }) so lock-screen scrubbers stay accurate. Wrap setActionHandler() in try: unknown actions throw TypeError (Chrome 152 accepts togglemicrophone and hangup for video calls but rejects voiceactivity). Firefox for Android exposes the API without showing controls.9.6.6 Encrypted Media Extensions
Encrypted Media Extensions (EME) play DRM-protected video by relaying messages between a license server and a Content Decryption Module (CDM) inside the browser, such as Widevine (Chrome, Firefox), PlayReady (Edge) or FairPlay (Safari), and decrypted frames never reach your script. requestMediaKeySystemAccess(keySystem, configs) picks a CDM and video.setMediaKeys() attaches it; the video's encrypted event supplies init data for session.generateRequest(); the session's message event yields a license request that you fetch() to the DRM vendor; session.update(license) delivers the keys. Only Clear Key (org.w3.clearkey) is mandatory in every browser. In practice, Shaka Player, dash.js or hls.js (Section 9.6.8) run this exchange for you.9.6.7 Media Source Extensions
Media Source Extensions (MSE) let JavaScript feed bytes to a <video> instead of a URL: the basis of adaptive streaming, ad insertion and live time-shifting. Attach a MediaSource through a blob URL and append fragmented MP4 or WebM segments to a SourceBuffer, one at a time.const ms = new (window.ManagedMediaSource ?? window.MediaSource)();
video.disableRemotePlayback = true; // required by ManagedMediaSource
video.src = URL.createObjectURL(ms);
await new Promise((resolve) => ms.addEventListener('sourceopen', resolve, { once: true }));
const buffer = ms.addSourceBuffer('video/mp4; codecs="avc1.4d401f"');
for (const url of [`${base}init.mp4`, ...[0, 1, 2, 3, 4].map((i) => `${base}segment_${i}.m4s`)]) {
buffer.appendBuffer(await (await fetch(url)).arrayBuffer());
await new Promise((resolve) => buffer.addEventListener('updateend', resolve, { once: true }));
}
ms.endOfStream(); // video.buffered: 0-20.1 s
9.6.8 Dash.js and hls.js
An adaptive stream is a manifest (.mpd for MPEG-DASH, .m3u8 for HTTP Live Streaming) listing renditions and their segments. A player library reads it, measures throughput, picks a rendition, feeds MSE and handles EME.9.6.9 WebVTT
WebVTT is the caption format <track> loads (Section 2.4.5): a WEBVTT header, then cues such as 01:10.000 --> 01:14.000 line:0 followed by text with <b>, <i> or <v Speaker> tags. Style them with video::cue. Script them too: video.addTextTrack('captions') returns a track, track.addCue(new VTTCue(70, 74, 'So beat it')) adds a cue, and cuechange with track.activeCues lets you sync a transcript or draw custom captions (track.mode = 'hidden' keeps events but hides native rendering). OpenAI's MIT-licensed Whisper (https://github.com/openai/whisper) writes captions with whisper talk.mp4 --output_format vtt.9.6.10 Web Speech
Speech synthesis works everywhere with the operating system's voices. Recognition is Chromium's SpeechRecognition (unprefixed since 139) or Safari's webkitSpeechRecognition. Chrome's default recognizer sends audio to a web service and fails offline; processLocally = true keeps it on the device after SpeechRecognition.install().const rec = new (window.SpeechRecognition ?? window.webkitSpeechRecognition)();
Object.assign(rec, { lang: 'en-US', interimResults: true }); // words appear while you speak
rec.onresult = (e) => (out.textContent = [...e.results].map((r) => r[0].transcript).join(''));
listenButton.onclick = () => rec.start(); // prompts for the microphone
const local = await rec.constructor.available?.({ langs: ['en-US'], processLocally: true });
speakButton.onclick = () => {
const voices = speechSynthesis.getVoices().filter((v) => v.lang === 'en-US'); // loads async
const utterance = new SpeechSynthesisUtterance(`You said: ${out.textContent}`);
speechSynthesis.speak(Object.assign(utterance, { voice: voices[0], rate: 1.2 }));
};
9.6.11 Web MIDI
MIDI, standardized in 1983, is still how keyboards, drum pads and synthesizers talk: three-byte messages such as [0x90, 60, 100] (note on, middle C, velocity 100). navigator.requestMIDIAccess() asks permission and resolves to a MIDIAccess whose inputs and outputs maps hold the connected ports. Set input.onmidimessage = (e) => output.send(e.data) to loop a keyboard through, or schedule a note-off with output.send([0x80, 60, 0], performance.now() + 500); statechange reports devices being plugged in. Test on a desktop with a USB instrument; headless Chrome denies the permission, and Safari lacks the API. WEBMIDI.js (https://github.com/djipco/webmidi) (npm i webmidi, Apache-2.0) hides the raw bytes behind methods such as playNote().9.6.12 Web Audio
Web Audio processes sound as a graph of nodes in an AudioContext. Sources (oscillators, decoded buffers, media elements, microphone streams) flow through effects (GainNode, BiquadFilterNode, ConvolverNode reverb, PannerNode for 3D) into ctx.destination, and an AnalyserNode can tap the signal for visualizers. The graph runs on a real-time audio thread, and every AudioParam can be automated with sample accuracy.
Figure 9.13: The audio graph built by the next listingconst ctx = new OfflineAudioContext({ numberOfChannels: 2, length: 48000, sampleRate: 48000 });
const osc = new OscillatorNode(ctx, { type: 'sawtooth', frequency: 440 });
const gain = new GainNode(ctx, { gain: 0 });
osc.connect(new BiquadFilterNode(ctx, { type: 'lowpass', frequency: 1000 })).connect(gain)
.connect(new StereoPannerNode(ctx, { pan: -0.5 })).connect(ctx.destination);
gain.gain.linearRampToValueAtTime(0.8, 0.05).exponentialRampToValueAtTime(0.01, 1); // envelope
osc.start();
const buffer = await ctx.startRendering(); // 1 s, rendered faster than real time
const peak = (ch) => Math.max(...buffer.getChannelData(ch).map(Math.abs));
const [left, right] = [peak(0), peak(1)]; // 0.669 and 0.277: panned left
9.6.13 Tone.js
Tone.js (https://github.com/Tonejs/Tone.js) (npm i tone, MIT, version 15.1.22) puts a digital audio workstation on Web Audio: synths, samplers, effects, and a Transport that schedules loops and sequences in musical time ('8n' is an eighth note). Tone objects wrap native nodes through the standardized-audio-context package and schedule events 0.1 s ahead (the context's lookAhead) so a busy main thread does not delay them.startButton.onclick = async () => {
await Tone.start(); // unlock audio inside a click
const synth = new Tone.PolySynth(Tone.Synth).connect(new Tone.Reverb(1.5).toDestination());
new Tone.Sequence((time, note) => synth.triggerAttackRelease(note, '8n', time),
['C4', 'E4', 'G4', ['A4', 'G4'], 'E4', 'D4', 'C4', null]).start(0);
Tone.getTransport().bpm.value = 96;
Tone.getTransport().start();
};
9.6.14 WebCodecs
WebCodecs exposes the browser's own, often hardware, codecs frame by frame: VideoEncoder, VideoDecoder, AudioEncoder, AudioDecoder and ImageDecoder, working on VideoFrame, AudioData and EncodedVideoChunk objects. Video editors, cloud gaming and low-latency streaming over WebTransport (Section 9.2.12) use it where MediaRecorder and MSE hide too much.const chunks = [];
const encoder = new VideoEncoder({ output: (chunk) => chunks.push(chunk), error: console.error });
encoder.configure({ codec: 'vp09.00.10.08', width: 640, height: 360, bitrate: 1e6, framerate: 30 });
const g = new OffscreenCanvas(640, 360).getContext('2d');
for (let i = 0; i < 60; i++) {
g.fillStyle = `hsl(${i * 6} 80% 50%)`;
g.fillRect(0, 0, 640, 360);
const frame = new VideoFrame(g.canvas, { timestamp: (i * 1e6) / 30 }); // microseconds
encoder.encode(frame, { keyFrame: i % 30 === 0 });
frame.close(); // release memory immediately
}
await encoder.flush(); // Chrome 152: 60 chunks, 121,210 bytes
9.6.15 Audio Output and Session Control
Two small APIs decide where a page's sound goes and how it mixes with the rest of the device. HTMLMediaElement.setSinkId(deviceId) moves an element's playback to a chosen speaker or headset, and AudioContext.setSinkId() (Chrome 110) does the same for a Web Audio graph (Section 9.6.12), firing sinkchange when the route changes. IDs come from enumerateDevices(), but a page never granted microphone access sees one anonymous audiooutput entry with an empty ID and label, so ask for a stream first. Headless Chrome 152 printed:const all = await navigator.mediaDevices.enumerateDevices();
const outs = all.filter((d) => d.kind === 'audiooutput');
outs.forEach((d) => console.log(d.deviceId.slice(0, 14), `"${d.label}"`));
const audio = document.createElement('audio');
await audio.setSinkId(outs[0].deviceId); // NotAllowedError if speaker-selection is blocked
default "Default - Speakers (2- High Definition Audio Device)"
8e1b8565ca064a "Digital Output (2- High Definition Audio Device)"
9.7 Device
Device APIs let a page read touch screens, controllers, sensors and location, keep the screen awake, and even talk to Bluetooth, USB and serial hardware. They all pass the gates of Section 9.1: a secure context (localhost counts), often a permission prompt or a user click, and feature detection, because support is uneven. Mozilla rates Web Bluetooth, WebUSB, WebHID and Web NFC as harmful and Apple opposes WebUSB and Web Serial, so the lower half of the table below is Chromium only (Chrome, Edge, Opera, Samsung Internet). Outputs below come from headless Chrome 152; hardware-only examples say so.9.7.1 Touch Events
Touch events (touchstart, touchmove, touchend, touchcancel) carry three TouchLists: touches (every finger on the screen), targetTouches (fingers that started on this element) and changedTouches (fingers that changed in this event). Each Touch has an identifier, clientX/Y, pageX/Y, radiusX/Y and force. Desktop Safari lacks them, so prefer pointer events (Section 9.7.7) and keep touch events for multi-finger gestures such as a pinch:const gap = ([a, b]) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
let start = 0;
photo.ontouchstart = (e) => { if (e.touches.length === 2) start = gap(e.touches); };
photo.ontouchmove = (e) => {
if (e.touches.length === 2) photo.style.scale = (gap(e.touches) / start).toFixed(2);
};
9.7.2 Permissions API
navigator.permissions.query() (Section 9.1) reports granted, denied or prompt without prompting, so use it to decide whether to show an "Enable location" button. Names are not portable. Headless Chrome 152 at http://localhost answered:for (const name of ['geolocation', 'accelerometer', 'bluetooth']) {
try { console.log(name, (await navigator.permissions.query({ name })).state); }
catch (e) { console.log(name, e.name); }
}
geolocation prompt
accelerometer granted
bluetooth TypeError
9.7.3 Geolocation
navigator.geolocation estimates position from GPS, Wi-Fi and IP data. getCurrentPosition(success, error, options) asks once; watchPosition() reports every move and returns an id for clearWatch(id). The first call prompts for permission, so make it from a click.const where = () => new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject,
{ enableHighAccuracy: true, timeout: 5000, maximumAge: 60_000 }));
try {
const { coords } = await where();
const { latitude, longitude, accuracy, speed } = coords;
console.log(`${latitude}, ${longitude} ±${accuracy} m, speed ${speed}`);
} catch (err) {
console.log(`Error ${err.code}: ${err.message}`); // 1 denied, 2 unavailable, 3 timeout
}
9.7.4 Sensors
The portable motion API is the deviceorientation event (angles alpha, beta, gamma) with its sibling devicemotion (acceleration in m/s²), Widely available since March 2026. iOS delivers them only after DeviceOrientationEvent.requestPermission() runs from a click, and Chrome 152 added that method too. The Generic Sensor API (Accelerometer, Gyroscope, AbsoluteOrientationSensor and relatives) adds a sampling frequency but ships only in Chromium.tiltButton.onclick = async () => {
if (await DeviceOrientationEvent.requestPermission?.() === 'denied') return;
addEventListener('deviceorientation', (e) => { // beta: front-back, gamma: left-right
ball.style.translate = `${e.gamma * 2}px ${e.beta * 2}px`;
});
};
const acl = new Accelerometer({ frequency: 30 });
acl.onreading = () => console.log(acl.x, acl.y, acl.z); // includes gravity
acl.onerror = (e) => console.log(e.error.name); // NotAllowedError, NotReadableError
acl.start();
9.7.5 Keyboard API
KeyboardEvent.code names a physical key (KeyW is the same physical key on every layout), while key is the character it types (Section 8.7.4). The Chromium-only Keyboard API serves fullscreen games and remote desktops: getLayoutMap() tells you what to print in "Move with W A S D", and lock() captures keys the browser or OS normally keeps, such as Escape, until unlock(). Headless Chrome has a US layout:const layout = await navigator.keyboard.getLayoutMap();
const keys = ['KeyW', 'KeyA', 'KeyS', 'KeyD'].map((code) => layout.get(code).toUpperCase());
console.log(`Move with ${keys.join(' ')} (${layout.size} keys mapped)`);
await navigator.keyboard.lock(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'Escape']);
9.7.6 Gamepad
The Gamepad API, Widely available since 2019, reads USB and Bluetooth controllers. For privacy, a pad appears only after the user presses a button, which fires gamepadconnected. There are no input events: poll navigator.getGamepads() every frame. With mapping === 'standard', buttons[0] is the bottom face button and axes[0] the left stick's horizontal position (-1 to 1).(function loop() {
const pad = navigator.getGamepads()[0]; // [null, null, null, null] until a press
if (pad && Math.abs(pad.axes[0]) > 0.1) hero.x += pad.axes[0] * 5; // 0.1 dead zone
if (pad?.buttons[0].pressed) pad.vibrationActuator?.playEffect('dual-rumble', { duration: 150 });
requestAnimationFrame(loop);
})();
9.7.7 Pointer Events
Pointer events, Widely available since January 2023, give mouse, pen and touch one model: pointerdown, pointermove, pointerup, pointercancel and the over/out/enter/leave family, with pointerId, pointerType, pressure, width/height, tiltX/tiltY and isPrimary. setPointerCapture() keeps a drag's events on your element after the finger leaves it. Playwright dragged the mouse and tapped the screen in headless Chrome 152:const pad = document.querySelector('#pad'); // CSS: touch-action: none
pad.addEventListener('pointerdown', (e) => pad.setPointerCapture(e.pointerId));
for (const type of ['pointerdown', 'pointerup']) {
pad.addEventListener(type, (e) => console.log(
`${type} ${e.pointerType}#${e.pointerId} at ${e.offsetX},${e.offsetY} pressure=${e.pressure}`));
}
pointerdown mouse#1 at 10,10 pressure=0.5
pointerup mouse#1 at 40,30 pressure=0
pointerdown touch#2 at 120,80 pressure=1
pointerup touch#2 at 120,80 pressure=0
9.7.8 Pointer Lock
Pointer lock hides the cursor and reports raw movementX/movementY deltas with no screen edges, for first-person games and 3D viewers. Unlike pointer capture it lasts until document.exitPointerLock() or the user presses Escape. It needs a click, so a headless run proves nothing; desktop Chrome, Firefox and Safari support it.canvas.onclick = () => canvas.requestPointerLock({ unadjustedMovement: true }); // no acceleration
const look = (e) => { camera.rotation.y -= e.movementX * 0.002; };
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement === canvas) document.addEventListener('mousemove', look);
else document.removeEventListener('mousemove', look);
});
9.7.9 Screen Wake Lock
A recipe, boarding pass or presenter view should not dim. navigator.wakeLock.request('screen') returns a WakeLockSentinel; the browser releases it when the tab is hidden or the battery runs low, so request it again when the page becomes visible. The API became Baseline Newly available in March 2025.let lock = null;
const keepAwake = async () => { lock = await navigator.wakeLock.request('screen'); };
cookButton.onclick = keepAwake;
document.addEventListener('visibilitychange', () => {
if (lock?.released && document.visibilityState === 'visible') keepAwake();
});
9.7.10 Vibration
navigator.vibrate(200) buzzes for 200 ms; navigator.vibrate([200, 100, 200]) alternates on and off, and navigator.vibrate(0) stops. It returns true when the pattern is accepted, not when the motor ran. Only Chrome on Android vibrates: Firefox removed the method in version 129, Safari never shipped it, and Chrome ignores calls made before the user has interacted with the page. Treat vibration as an optional extra on top of visible feedback.9.7.11 Presentation API
The Chromium-only Presentation API opens a second page on an external display and messages it. From a click, call new PresentationRequest(['receiver.html']).start() to pick a screen, then connection.send(...); the receiver listens on navigator.presentation.receiver.connectionList. request.getAvailability() decides whether to show a cast button (headless Chrome reports false). For two desktop monitors, the Window Management API (Section 9.5.3) is often simpler.9.7.12 Web NFC
NFC (Near Field Communication) is short-range radio that works when a phone touches a tag. Web NFC reads and writes NDEF messages on tags such as museum labels or inventory stickers, and it exists only in Chrome for Android (since version 89); low-level tag commands are not exposed.const reader = new NDEFReader();
await reader.scan(); // from a click; prompts once
reader.onreading = ({ serialNumber, message }) => console.log(serialNumber, message.records);
await reader.write({ records: [{ recordType: 'url', data: 'https://example.com/item/42' }] });
9.7.13 Web Bluetooth
Web Bluetooth talks to Bluetooth Low Energy devices such as heart-rate straps through GATT services. From a click, navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }) opens a chooser; then device.gatt.connect(), getPrimaryService('heart_rate'), getCharacteristic('heart_rate_measurement') and startNotifications() deliver characteristicvaluechanged events carrying a DataView. It runs in Chrome 70+ on desktop (off by default on Linux) and 56+ on Android, not in Firefox or Safari. Headless Chrome, with no adapter, resolves navigator.bluetooth.getAvailability() to false.9.7.14 WebUSB, Web Serial and WebHID
Three Chromium APIs reach wired hardware, each through a browser chooser opened from a click. WebUSB (Chrome 61) exchanges packets with USB devices that have no OS driver, as firmware flashers do. Web Serial (Chrome 89, Firefox 151 desktop behind a site permission add-on) streams bytes to microcontrollers, 3D printers and CNC machines. WebHID (Chrome 89, desktop) handles human interface devices the Gamepad API ignores, such as macro keypads. getDevices() or getPorts() return previously approved devices without a prompt.connectButton.onclick = async () => {
const port = await navigator.serial.requestPort({ filters: [{ usbVendorId: 0x2341 }] });
await port.open({ baudRate: 115200 });
const writer = port.writable.getWriter();
await writer.write(new TextEncoder().encode('LED ON\n')); // then writer.releaseLock()
};
9.7.15 Battery, Device Memory and Compute Pressure
These Chromium-only hints scale work to the device: skip autoplay on a low battery, load lighter assets on a small-memory phone, or lower a video call's frame rate under CPU pressure. Firefox 52 removed getBattery(). navigator.deviceMemory rounds RAM to coarse buckets (2 to 32 GB on desktop since Chrome 147), Chrome 152 adds the navigator.cpuPerformance tier (1-4, 0 unknown), and PressureObserver (Chrome 125, desktop) reports CPU state changes. Headless Chrome 152 printed:const battery = await navigator.getBattery?.();
console.log(battery?.level, battery?.charging, navigator.deviceMemory, navigator.cpuPerformance);
const observer = new PressureObserver(([record]) => console.log(record.source, record.state));
await observer.observe('cpu', { sampleInterval: 2000 }); // nominal, fair, serious, critical
1 true 32 4
cpu nominal
9.7.16 Contacts, Idle Detection and Local Fonts
Three Chromium-only APIs reach into data the operating system owns. Each needs a permission prompt raised from a user gesture, so none can be exercised in an automated browser. navigator.contacts.select() opens the platform's own contact sheet and returns only the entries and properties the user ticked â the page never sees the address book. IdleDetector reports whether the user has stopped interacting and whether the screen is locked, which is how a chat app shows an "away" badge; its threshold must be at least 60000 ms or start() throws TypeError. queryLocalFonts() lists the machine's installed fonts for design tools that need a desktop-style font menu; a denied permission resolves to an empty array instead of throwing, so check length.const picked = await navigator.contacts.select(['name', 'tel'], { multiple: true });
if (await IdleDetector.requestPermission() === 'granted') {
const detector = new IdleDetector();
detector.onchange = () => console.log(detector.userState, detector.screenState);
await detector.start({ threshold: 60_000 }); // 'active'/'idle', 'locked'/'unlocked'
}
const fonts = await window.queryLocalFonts(); // [{ postscriptName, fullName, family, style }]
9.8 Security
These APIs handle money, identity, keys and process isolation, so nearly all require a secure context (Section 9.1) and a user gesture for sensitive calls. Section 2.8 covers the HTML and header side (CSP, Permissions-Policy, sandbox, Trusted Types) and Section 8.8.4 sanitization; this section adds the JavaScript and reporting side.9.8.1 Payment Request
Payment Request replaces a checkout form with a browser sheet filled from the user's wallet; show() returns a PaymentResponse for your processor and complete() closes the sheet. Methods are URLs such as https://google.com/pay or https://apple.com/apple-pay (Safari's only one); basic-card was removed in Chrome 100. Support is Limited: Chromium and Safari.const details = { total: { label: 'Donation', amount: { currency: 'USD', value: '65.00' } } };
const methods = ['basic-card', 'https://google.com/pay'].map((id) => ({ supportedMethods: id }));
for (const m of methods) {
console.log(m.supportedMethods, await new PaymentRequest([m], details).canMakePayment());
}
payButton.onclick = async () => { // show() needs a click, and real wallets need real data
const response = await new PaymentRequest([methods[1]], details).show();
const charged = await fetch('/charge', { method: 'POST', body: JSON.stringify(response) });
await response.complete(charged.ok ? 'success' : 'fail');
};
basic-card false
https://google.com/pay true
9.8.2 Credential Management
navigator.credentials (Baseline Widely available) offers get(), store(), create() and preventSilentAccess(). The get() option picks the credential: password (saved passwords), identity (FedCM "Sign in with..."), otp (SMS codes), all Chromium only, or publicKey (passkeys, Section 9.8.3). mediation is 'silent', 'optional', 'required' or 'conditional'; it replaced the old unmediated: true.if ('PasswordCredential' in window) {
const saved = await navigator.credentials.get({ password: true, mediation: 'silent' });
if (saved) await signIn(saved.id, saved.password); // returning user skips the form
}
async function onLoginSucceeded(form) { // inputs use autocomplete="username" / "current-password"
if (window.PasswordCredential) await navigator.credentials.store(new PasswordCredential(form));
}
logoutButton.onclick = () => navigator.credentials.preventSilentAccess();
9.8.3 Web Authentication (WebAuthn) and Passkeys
WebAuthn (Baseline Widely available) replaces passwords with key pairs. An authenticator (phone, password manager or security key) keeps the private key and signs server challenges after a biometric or PIN check. The signed data includes the origin, so a phishing domain's signatures fail. A passkey is a discoverable credential synced by iCloud Keychain, Google Password Manager or another manager.
Figure 9.14: Passkey registration and sign-in: only public keys and
signatures reach the serverconst getJSON = async (url) => (await fetch(url)).json();
const post = (url, data) => fetch(url, { method: 'POST', body: JSON.stringify(data) }); // toJSON()
const created = await navigator.credentials.create({ // run from a "Create passkey" click
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(await getJSON('/passkey/new')) });
await post('/passkey/new', created);
// Sign-in page: <input name="username" autocomplete="username webauthn">
if ((await PublicKeyCredential.getClientCapabilities()).conditionalGet) {
const assertion = await navigator.credentials.get({ mediation: 'conditional',
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(await getJSON('/passkey/login')) });
await post('/passkey/login', assertion);
}
9.8.4 Web Crypto
crypto.getRandomValues() works anywhere, but crypto.randomUUID() and crypto.subtle (Baseline Widely available) are undefined outside secure contexts. subtle provides encrypt() (AES-GCM, AES-CBC, RSA-OAEP), sign() (Ed25519, ECDSA, RSA-PSS, HMAC), digest() (SHA-256/384/512), deriveKey() (PBKDF2, HKDF, ECDH, X25519) and key import, export and wrapping.const { subtle } = crypto;
const bytes = (text) => new TextEncoder().encode(text);
const salt = crypto.getRandomValues(new Uint8Array(16)); // store salt and iv with the data
const iv = crypto.getRandomValues(new Uint8Array(12));
const pw = await subtle.importKey('raw', bytes('correct horse'), 'PBKDF2', false, ['deriveKey']);
const key = await subtle.deriveKey({ name: 'PBKDF2', salt, iterations: 600_000, hash: 'SHA-256' },
pw, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const sealed = await subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes('Meet at 9'));
const opened = await subtle.decrypt({ name: 'AES-GCM', iv }, key, sealed);
console.log(sealed.byteLength, 'bytes sealed ->', new TextDecoder().decode(opened));
const { publicKey, privateKey } = await subtle.generateKey('Ed25519', false, ['sign', 'verify']);
const signature = await subtle.sign('Ed25519', privateKey, bytes('Invoice #42'));
console.log(signature.byteLength, 'byte signature valid:',
await subtle.verify('Ed25519', publicKey, signature, bytes('Invoice #42')));
25 bytes sealed -> Meet at 9
64 byte signature valid: true
9.8.5 Permissions Policy
Section 2.8.6 sets policies with Permissions-Policy and allow. Chromium (Chrome 74) exposes the result as document.featurePolicy with allowsFeature(), features() and allowedFeatures(); Firefox and Safari lack it, so treat absence as "unknown". Blocked calls fail and queue a permissions-policy-violation report.<pre id="out" style="font: 14px/1.5 monospace; margin: 0"></pre>
<script>
const say = (text) => { out.textContent += text + '\n'; };
const frame = Object.assign(document.createElement('iframe'),
{ allow: "camera 'none'; geolocation 'none'", srcdoc: 'widget', hidden: true });
frame.onload = () => {
const win = frame.contentWindow;
new win.ReportingObserver(([r]) => say(`report: ${r.type} ${r.body.featureId}`)).observe();
say(`frame allows camera: ${win.document.featurePolicy.allowsFeature('camera')}`);
win.navigator.geolocation.getCurrentPosition(() => {},
(e) => say(`geolocation error ${e.code}`));
};
document.body.append(frame);
</script>
Figure 9.15: Browser output of Listing 9.78A policy is not a permission: the user may still block an allowed
camera (Section 9.7.2).9.8.6 Trusted Types, the Sanitizer API and CSP Reporting
Policies themselves are in Sections 2.8.5, 2.8.7 and 8.8.4. When one fires, the page gets a securitypolicyviolation event (all engines) with effectiveDirective, blockedURI and a code sample. For server reports, send Reporting-Endpoints: csp="https://example.com/r" and add report-to csp to the policy.<!doctype html>
<meta http-equiv="Content-Security-Policy"
content="img-src 'self'; script-src 'unsafe-inline'; require-trusted-types-for 'script'">
<pre id="out" style="font: 14px/1.5 monospace; margin: 0"></pre>
<script>
const say = (text) => { out.textContent += text + '\n'; };
document.addEventListener('securitypolicyviolation',
(e) => say(`${e.effectiveDirective}: ${e.blockedURI} ${e.sample}`));
const box = document.createElement('div');
box.append(Object.assign(new Image(), { src: 'https://tracker.example/pixel.gif' }));
try { box.innerHTML = 'hi'; } catch (e) { say(`innerHTML: ${e.name}`); }
box.setHTML('<p onclick="steal()">Nice <b>post</b> <u>really</u>',
{ sanitizer: { elements: ['p', 'b'] } });
say(`setHTML: ${box.innerHTML}`);
</script>
Figure 9.16: Browser output of Listing 9.79setHTML() (Sanitizer API: Chrome 146, Firefox 148, not Safari) is
always XSS-safe, so Trusted Types allow it. An elements allow-list drops
other elements with their text ("really" vanished) unless they are
listed in replaceWithChildrenElements.9.8.7 Cross-origin Isolation
Since Spectre (2018), SharedArrayBuffer requires cross-origin isolation (Firefox 79, Chrome 92). Send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, under which cross-origin subresources load only with CORS or Cross-Origin-Resource-Policy: cross-origin.<img id="plain" src="http://127.0.0.1:8937/pic.png">
<img id="corp" src="http://127.0.0.1:8937/corp-pic.png">
<script>
onload = () => {
console.log(`isolated: ${crossOriginIsolated}, no-CORP image: ${plain.naturalWidth > 0}`);
console.log(`CORP image: ${corp.naturalWidth > 0}`);
const shared = new SharedArrayBuffer(4); // ReferenceError when not isolated
const code = 'onmessage = (e) => { new Int32Array(e.data)[0] = 42; postMessage(0); };';
const worker = new Worker(URL.createObjectURL(new Blob([code])));
worker.onmessage = () => console.log('worker wrote', new Int32Array(shared)[0]);
worker.postMessage(shared);
};
</script>
isolated: true, no-CORP image: false
CORP image: true
worker wrote 42
9.8.8 Privacy Sandbox APIs
Privacy Sandbox was Google's plan to retire third-party cookies and hand advertisers browser APIs that could pick audiences and count conversions without a cross-site identifier. Neither half happened. On April 22, 2025 Google dropped the promised cookie prompt and kept Chrome's current behavior, so third-party cookies still work in Chrome; users switch them off under Privacy and security, and Incognito blocks them by default. On October 17, 2025 Google retired the advertising APIs too, citing low adoption, and Chrome's feature-status page now files them under "deprecate and remove".const live = ['browsingTopics' in document, 'runAdAuction' in navigator,
'sharedStorage' in window, 'HTMLFencedFrameElement' in window];
console.log('Topics, Protected Audience, Shared Storage, Fenced Frames:', live.join(' '));
try { await document.browsingTopics(); } catch (err) { console.log('call:', err.name); }
Topics, Protected Audience, Shared Storage, Fenced Frames: true true true true
call: NotSupportedError
9.8.9 WebOTP
Phone verification normally costs the user a trip out of your page: read the SMS, memorize six digits, come back, type them. WebOTP removes the trip. navigator.credentials.get({ otp: { transport: ['sms'] } }) (Section 9.8.2) returns a promise that stays pending until a matching SMS arrives; the browser asks once whether to use it, and the resulting OTPCredential carries the digits in code. The match is on the message, not the sender: the browser reads only the last line, which must be @trailnotes.example #123456 â @ plus the host of your origin with no scheme, port or path, a space, # plus the code. A cross-origin iframe appends its own origin (@top.example #123456 @embed.example) and needs otp-credentials from the embedder (Section 9.8.5).const input = document.querySelector('input[autocomplete="one-time-code"]');
const form = input.closest('form');
if ('OTPCredential' in window) {
const ac = new AbortController();
form.addEventListener('submit', () => ac.abort()); // the user typed it first
setTimeout(() => ac.abort(), 60_000); // never leave the prompt standing
const options = { otp: { transport: ['sms'] }, signal: ac.signal };
try {
input.value = (await navigator.credentials.get(options)).code;
form.requestSubmit(); // still verify the code server-side
} catch (err) {
if (err.name !== 'AbortError') console.warn(err.name); // declined, or timed out
}
}
9.9 Observers
Observers replace polling and scroll or resize handlers with batched callbacks. All have observe() and disconnect().9.9.1 ReportingObserver
Deprecation and intervention warnings (the browser blocking a heavy ad, say) normally reach only DevTools, and often occur only on users' devices. ReportingObserver (Baseline since March 2026) passes them to your code; buffered: true adds earlier reports. These two report types are still mostly Chromium.<script>
new ReportingObserver((reports) => reports.forEach(({ type, body }) => {
console.log(`${type}: ${body.id}\n "${body.message.slice(0, 62)}..."`);
}), { types: ['deprecation', 'intervention'], buffered: true }).observe();
const xhr = new XMLHttpRequest();
xhr.open('GET', location.href, false); // synchronous XHR on the main thread is deprecated
xhr.send();
</script>
deprecation: XMLHttpRequestSynchronousInNonWorkerOutsideBeforeUnload
"Synchronous `XMLHttpRequest` on the main thread is deprecated ..."
9.9.2 ResizeObserver
ResizeObserver fires whenever an element's box changes size, whatever the cause. Entries carry contentBoxSize and borderBoxSize arrays.<div id="card" style="width: 200px; padding: 10px; border: 2px solid #1565c0">Card</div>
<pre id="out" style="font: 14px/1.5 monospace; margin: 8px 0 0"></pre>
<script>
new ResizeObserver(([{ target, contentBoxSize: [box], borderBoxSize: [border] }]) => {
target.textContent = box.inlineSize < 300 ? 'Compact card' : 'Wide card with details';
out.textContent += `content ${box.inlineSize}px, border box ${border.inlineSize}px\n`;
}).observe(card);
requestAnimationFrame(() => requestAnimationFrame(() => { card.style.width = '320px'; }));
</script>
Figure 9.17: Browser output of Listing 9.84The width changes one frame later, as when a sidebar collapses. Use
container queries (Section 4.13) for pure styling. Resizing the target
inside the callback raises "ResizeObserver loop completed with
undelivered notifications".9.9.3 IntersectionObserver
IntersectionObserver reports elements entering or leaving the viewport or a scrolling root, for lazy loading, infinite scroll and ad impressions. threshold sets the visible fractions that trigger callbacks; rootMargin grows the root box so work starts early.<div id="feed" style="height: 100px; width: 300px; overflow-y: auto; border: 1px solid #999"></div>
<pre id="out" style="font: 14px/1.5 monospace; margin: 8px 0 0"></pre>
<script>
const row = (_, i) => `<p style="height: 25px; margin: 0">${i + 1}</p>`;
feed.innerHTML = Array.from({ length: 12 }, row).join('');
feed.scrollTop = 110; // rows 5-9 are (partly) visible
const ids = (entries, inView) => entries.filter((e) => e.isIntersecting === inView)
.map((e) => e.target.textContent).join(' ');
const observer = new IntersectionObserver((entries) => {
out.textContent += `in [${ids(entries, true)}] out [${ids(entries, false)}]\n`;
}, { root: feed, rootMargin: '0px 0px 30px 0px' }); // report rows 30px before they appear
feed.querySelectorAll('p').forEach((p) => observer.observe(p));
</script>
Figure 9.18: Browser output of Listing 9.85The first callback reports each target's initial state; row 10, still
out of sight, is in because of rootMargin. Later callbacks list only
rows whose state changed. Images and iframes need only
loading="lazy".9.9.4 PerformanceObserver
PerformanceObserver streams timeline entries as they are recorded, replacing polling of performance.getEntries(). Pass entryTypes, or one type with buffered: true to get earlier entries too; PerformanceObserver.supportedEntryTypes lists 15 in Chrome 152.<h1>Quarterly report</h1>
<script>
new PerformanceObserver((list) => list.getEntries().forEach((e) => console.log(e.entryType,
e.name || e.element.localName, Math.round(e.startTime), Math.round(e.duration))))
.observe({ entryTypes: ['largest-contentful-paint', 'mark', 'measure'] });
performance.mark('build-start');
for (let i = 0, sum = 0; i < 3e7; i++) sum += i; // slow work
performance.measure('build-report', 'build-start');
</script>
mark build-start 359 0
measure build-report 359 35
largest-contentful-paint h1 424 0
9.10 Performance and Core Web Vitals
DevTools and Lighthouse (Section 1.3.3) give you lab data from one synthetic visit. Real users bring slow phones and flaky networks, so you also need field data: timings collected in their browsers and sent to your analytics (real-user monitoring). Every timing API writes PerformanceEntry objects into one timeline, read with performance.getEntriesByType() or a PerformanceObserver (Section 9.9.4).<!doctype html>
<h1>Product page</h1>
<p id="promo"></p>
<img src="/hero.svg" width="1000" height="400" alt="Hero">
<button id="buy">Add to cart</button>
<script type="module">
import { onTTFB, onLCP, onINP, onCLS }
from 'https://cdn.jsdelivr.net/npm/web-vitals@6.2.2/dist/web-vitals.js';
const log = ({ name, value, rating }) => console.log(name, +value.toFixed(3), rating);
for (const on of [onTTFB, onLCP, onINP, onCLS]) on(log, { reportAllChanges: true });
setTimeout(() => { // late ad with no reserved space
promo.innerHTML = '<img src="/ad.svg" width="1000" height="250" alt="Ad">';
}, 1000);
buy.addEventListener('click', () => {
performance.mark('cart'); // User Timing
const end = performance.now() + 250;
while (performance.now() < end); // 250 ms of blocking work
console.log('measure', Math.round(performance.measure('add', 'cart').duration));
});
</script>
LCP 296 good
CLS 0 good
TTFB 61.8 good
LCP 912 good
CLS 0.147 needs-improvement
measure 250
INP 256 needs-improvement
<a href="other.html">Next page</a>
<script>
addEventListener('pageshow', (event) => {
const [nav] = performance.getEntriesByType('navigation');
console.log(`pageshow persisted=${event.persisted}, navigation type=${nav.type}`);
const reasons = nav.notRestoredReasons?.reasons ?? [];
if (reasons.length) console.log('not restored:', reasons.map((r) => r.reason).join());
});
addEventListener('pagehide', (event) => console.log(`pagehide persisted=${event.persisted}`));
if (location.search === '?block') addEventListener('unload', () => {});
</script>
pageshow persisted=false, navigation type=navigate
pagehide persisted=true
pageshow persisted=true, navigation type=navigate
pageshow persisted=false, navigation type=navigate
pageshow persisted=false, navigation type=back_forward
not restored: masked
9.11 Graphics and Compute APIs at a Glance
When the DOM and CSS are not enough, reach for these APIs; the Web Graphics volume of this series covers them in depth.<canvas id="c" width="120" height="40"></canvas>
<svg width="60" height="40"><path id="p" d="M10 4 L34 36" stroke="#2e7d32" stroke-width="4"/></svg>
<ul id="out" style="font: 15px system-ui; margin: 0"></ul>
<script type="module">
const show = (k, v) => out.insertAdjacentHTML('beforeend', `<li>${k}: <b>${v}</b></li>`);
const ctx = c.getContext('2d');
ctx.fillStyle = '#1565c0'; ctx.fillRect(0, 0, 120, 40);
show('Canvas pixel', ctx.getImageData(60, 20, 1, 1).data.join());
show('SVG path length', p.getTotalLength());
show('WebGL 2', document.createElement('canvas').getContext('webgl2') ? 'yes' : 'no');
show('WebGPU adapter', (await navigator.gpu?.requestAdapter()) ? 'found' : 'none');
show('OffscreenCanvas, WebXR', `${'OffscreenCanvas' in window}, ${'xr' in navigator}`);
const wasm = [0, 97, 115, 109, 1, 0, 0, 0, 1, 7, 1, 96, 2, 127, 127, 1, 127, 3, 2, 1, 0, 7, 10,
1, 6, 97, 100, 100, 84, 119, 111, 0, 0, 10, 9, 1, 7, 0, 32, 0, 32, 1, 106, 11];
const { instance } = await WebAssembly.instantiate(new Uint8Array(wasm));
show('addTwo(40, 2)', instance.exports.addTwo(40, 2));
</script>
Figure 9.19: Browser output of Listing 9.89Two smaller pieces sit underneath. The Geometry
interfaces â DOMMatrix, DOMPoint, DOMRect, DOMQuad, Baseline
Widely available â are the types Canvas, SVG, CSS transforms and
getBoundingClientRect() all speak, so new DOMMatrix().translate(10,
20).scale(2) composes a transform without string concatenation. The
CSS Painting API (Houdini) registers a worklet with
CSS.paintWorklet.addModule() so a paint(myBorder) value draws a
background with a Canvas-like context; Chromium 65 only, so declare a
plain background-image first.requestAdapter() resolves to null without a usable GPU, so keep a
WebGL fallback. Draw in a worker via transferControlToOffscreen()
(Section 9.4.1) to protect INP, and give canvas content a text
alternative.9.12 Built-in AI and Emerging APIs
These mostly single-engine APIs are progressive enhancements: always feature-detect. Chrome's built-in AI APIs run Gemini Nano and smaller expert models on the device, with no API key or per-token cost, and text never leaves the machine. The model downloads on first use and needs a desktop OS (Windows 10/11, macOS 13+, Linux, Chromebook Plus), 22 GB of free disk, and a GPU with over 4 GB of VRAM or 16 GB of RAM and 4 cores.<article>Long product reviews ...</article>
<button id="tldr">Summarize</button>
<script type="module">
const options = { type: 'tldr', length: 'short', outputLanguage: 'en' };
const state = 'Summarizer' in self ? await Summarizer.availability(options) : 'unsupported';
console.log('Summarizer', state);
const pair = { sourceLanguage: 'en', targetLanguage: 'fr' };
console.log('Translator', await self.Translator?.availability(pair));
tldr.addEventListener('click', async () => { // create() needs user activation
if (state === 'unavailable' || state === 'unsupported') return fetch('/api/summarize');
const summarizer = await Summarizer.create({ ...options, monitor: (m) =>
m.addEventListener('downloadprogress', (e) => console.log(`model ${e.loaded * 100}%`)) });
console.log(await summarizer.summarize(document.querySelector('article').innerText));
});
</script>
Summarizer unavailable
Translator downloadable
9.13 Polyfills and Feature Detection
Three tools keep new code working in older browsers. Feature detection (Section 9.1) asks whether a capability exists. A polyfill adds a missing runtime API, such as Object.groupBy, written in older JavaScript. A transpiler such as Babel rewrites new syntax, because no script can teach an old parser what ?. means. Baseline (Section 1.1.3) tells you when each fallback can go: use Widely available features natively, give Newly available ones a polyfill or fallback, and treat Limited ones as progressive enhancements.Test first, then load, so modern browsers pay nothing. The demo deletes Object.groupBy to imitate Safari 17.3.<ul id="out" style="font: 15px system-ui"></ul>
<script type="module">
const show = (text) => out.insertAdjacentHTML('beforeend', `<li>${text}</li>`);
delete Object.groupBy; // simulate an older browser
const missing = !('groupBy' in Object) || !globalThis.Iterator?.prototype.map;
show(`Object.groupBy before: ${typeof Object.groupBy}; polyfill needed: ${missing}`);
if (missing) await new Promise((onload, onerror) => document.head.append(Object.assign(
document.createElement('script'), { onload, onerror, crossOrigin: 'anonymous',
src: 'https://cdn.jsdelivr.net/npm/core-js-bundle@3.50.0/minified.js',
integrity: 'sha384-FFQ9vMUG5ncCF536DGix0yCvJeaR1Td99BfLaGw6OZDOA/jCgMOK3an7oNGZu4FJ' })));
const groups = Object.groupBy([1, 2, 3, 4], (n) => (n % 2 ? 'odd' : 'even'));
show(`Object.groupBy after: ${typeof Object.groupBy} → ${JSON.stringify(groups)}`);
</script>
Figure 9.20: Browser output of Listing 9.91Pin the version and add an integrity hash: the polyfill.io service
turned malicious in 2024 after changing owners (Section 2.8.2). The full
bundle weighs 273 KB minified (88 KB gzipped), so applications should
import per-feature modules from a build step instead.core-js (github.com/zloirock/core-js
(https://github.com/zloirock/core-js), MIT, about 25,500 stars, version
3.50.0) is the standard-library polyfill: one module per feature
(es.object.group-by, web.structured-clone), esnext.* proposals, and a
core-js-pure build that leaves globals alone. Its companion
core-js-compat records the first browser version with a correct
implementation of each module. Babel 8 (June 2026) removed useBuiltIns
and corejs from @babel/preset-env; use
babel-plugin-polyfill-corejs3.{
"targets": "baseline widely available",
"presets": ["@babel/preset-env"],
"plugins": [["polyfill-corejs3", { "method": "usage-global", "version": "3.50" }]]
}
const orders = [{ id: 2, status: 'paid' }, { id: 1, status: 'open' }];
const byStatus = Object.groupBy(orders, (order) => order.status);
const ids = orders.values().map((order) => order.id).toArray().toSorted();
import "core-js/modules/es.iterator.constructor.js";
import "core-js/modules/es.iterator.map.js";
import "core-js/modules/es.iterator.to-array.js";
import "core-js/modules/es.object.group-by.js";
import { features } from 'web-features';
const labels = { high: 'Widely available', low: 'Newly available', false: 'Limited' };
const ids = ['structured-clone', 'array-group', 'set-methods', 'iterator-methods', 'temporal'];
for (const { name, status: s } of ids.map((id) => features[id])) {
console.log(name.padEnd(22), labels[s.baseline].padEnd(17),
s.baseline_high_date ?? s.baseline_low_date ?? '-');
}
structuredClone() Widely available 2024-09-14
Array grouping Widely available 2026-09-05
Set methods Newly available 2024-06-11
Iterator methods Newly available 2025-03-31
Temporal Limited -
9.14 Third-Party Integrations
A few lines of vendor markup give your site comments, maps, video, bot checks, payments and analytics. Each embed also runs someone else's code on your page and may set cookies before the visitor agrees. Load vendor scripts async, isolate what you can in iframes (Sections 2.2.4 and 2.8.7), list vendor origins in your Content Security Policy (Section 2.8.5), and hold back anything that tracks until the visitor consents (Section 9.14.8).9.14.1 Social Plugins
Meta discontinued its Like button and Comments plugin on February 10, 2026 (they now collapse to invisible elements); the Share button, Page plugin, embedded posts and embedded video remain at developers.facebook.com/docs/plugins (https://developers.facebook.com/docs/plugins/). On X, "Embed post" or the oEmbed endpoint publish.x.com/oembed returns a <blockquote class="twitter-tweet"> that platform.x.com/widgets.js upgrades into an iframe. LinkedIn's "Embed this post" copies code for any post visible to Anyone. Widget scripts contact the network on every page load; for plain sharing, a link or navigator.share() sends nothing until the visitor clicks.9.14.2 Comment Systems
A hosted comment widget saves you from building moderation, spam filtering and accounts. Disqus is the classic drop-in snippet, stores comments on its own servers and earns its money mainly from advertising, so expect ads and tracking on your pages. Developer blogs lean toward two MIT-licensed tools that keep each page's thread in your own GitHub repository, with readers signing in through GitHub: giscus (https://giscus.app/) uses GitHub Discussions and promises "no tracking, no ads, always free", while utterances (https://github.com/utterance/utterances) uses GitHub Issues and has seen no commits since August 2024.9.14.3 Google Photos, Maps and YouTube Embeds
Maps and YouTube embed as iframes (Section 2.2.4); add loading="lazy" and a title. The Maps Embed API (google.com/maps/embed/v1/place?key=...&q=...) is free with no usage limits; restrict its key to your domain. The youtube-nocookie.com host (privacy-enhanced mode) keeps embedded views from personalizing the viewer's YouTube experience and ads, and lite-youtube-embed (https://github.com/paulirish/lite-youtube-embed) shows only a thumbnail until clicked, rendering about 224 times faster by its README's measure. Google Photos no longer exposes whole libraries: since March 31, 2025 the Library API sees only items your app uploaded, and the Picker API lets a user choose photos to hand over.9.14.4 Bot Protection
All three work the same way: a script scores the visitor, usually without a puzzle, and writes a token into your form, such as Turnstile's <div class="cf-turnstile" data-sitekey="...">. Your server must post that token with its secret key to the vendor's siteverify endpoint; a token checked only in the browser protects nothing.const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET,
response: req.body['cf-turnstile-response'] }); // field the widget adds
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await (await fetch(url, { method: 'POST', body })).json();
if (!result.success) return res.status(403).json(result['error-codes']);
9.14.5 Phone Authentication with Firebase
Firebase Authentication signs users in with a one-time SMS code. Enable the Phone provider in the console, then make three calls from firebase/auth (npm i firebase, version 12): new RecaptchaVerifier(auth, 'sign-in-button', { size: 'invisible' }) to prove a human is asking, signInWithPhoneNumber(auth, '+15551234567', verifier) to send the code, and confirmationResult.confirm(code) to finish, which resolves with the signed-in user; the firebase.auth() style in older tutorials is the pre-modular API. Phone Auth is not on the no-cost Spark plan: Blaze bills every SMS sent. Use up to 10 fictional test numbers during development, and set an SMS region allowlist, because SMS pumping (bots requesting codes to costly destinations) is a common attack on these forms.9.14.6 SMS Gateways: Clickatell and Twilio
An SMS gateway turns an HTTPS request into a text message. Call it from your server, never the browser, which would expose your credentials. Twilio's REST call is a POST to api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json with To, From and Body fields and your SID and auth token as Basic credentials; US messages start at $0.0083 per segment plus carrier fees. Clickatell now sells SMS to more than 230 territories inside a wider messaging platform that also covers WhatsApp, with pricing quoted by its sales team. One segment holds 160 GSM-7 characters; a single emoji switches to UCS-2 and cuts that to 70.9.14.7 Payments: PayPal and Stripe
Use the gateways' hosted pieces, such as Stripe Checkout or Elements and PayPal's JavaScript SDK buttons: card numbers then go straight to the gateway and never touch your server, keeping your PCI DSS scope small. Your server still creates the order and confirms payment from a webhook, never from a success message in the browser. For the browser-native Payment Request API, see Section 9.8.1.9.14.8 Analytics and Consent
Google Analytics 4 is free but sets cookies, so EU and UK privacy rules normally require consent first. Consent mode v2 loads the tag in a restricted state and unlocks it when the visitor agrees. Plausible (https://github.com/plausible/analytics) is the common cookieless alternative: no persistent identifiers, hosted from $9 a month for 10,000 pageviews, or self-hosted under AGPL-3.0.gtag('consent', 'default', { ad_storage: 'denied', ad_user_data: 'denied',
ad_personalization: 'denied', analytics_storage: 'denied' });
acceptButton.onclick = () => gtag('consent', 'update', { analytics_storage: 'granted' });
9.15 Browser Extensions
A browser extension is a small web app with extra privileges: it reads and changes pages, adds toolbar buttons and panels, and reacts to tabs, using the WebExtensions APIs that Chrome, Edge, Firefox and Safari share. Manifest V3 is the only format Chrome still runs: Chrome 138 was the last version able to run Manifest V2, and the Chrome Web Store removed the remaining V2 extensions on August 31, 2026. The subsections below build Link Counter, which counts each page's links and shows them on a badge and in a popup.9.15.1 Anatomy of a Manifest V3 Extension
An extension is a folder of web files plus manifest.json, which names entry points and permissions. Each piece has its own context: the background service worker handles events, content scripts run inside web pages, and extension pages (popup, side panel, options) are HTML documents served from a chrome-extension://<id>/ origin.{
"manifest_version": 3,
"name": "Link Counter",
"version": "1.0.0",
"description": "Counts the links on each page and shows the total on the toolbar badge.",
"action": { "default_title": "Link Counter", "default_popup": "popup.html" },
"background": { "service_worker": "background.js", "scripts": ["background.js"] },
"content_scripts": [
{ "matches": ["https://*/*"], "js": ["content.js"], "run_at": "document_idle" }
],
"permissions": ["storage"]
}
9.15.2 Service Workers, Content Scripts and Messaging
The background service worker is the extension's event hub. Chrome starts it for events and stops it after 30 seconds without events or API calls, so register listeners at the top level and keep state in chrome.storage, not in globals. Content scripts run inside matching pages in an isolated world: they share the page's DOM but not its JavaScript variables, and they can call only a few extension APIs such as runtime.sendMessage(). Everything else travels by message.
Figure 9.21: Link Counter's contexts and the messages between
themconst links = [...document.querySelectorAll('a[href]')];
const external = links.filter((a) => a.hostname && a.hostname !== location.hostname).length;
const msg = { type: 'links', title: document.title, total: links.length, external };
chrome.runtime.sendMessage(msg).then((reply) => console.log('[content] badge =', reply.badge));
chrome.runtime.onInstalled.addListener(({ reason }) => console.log('[sw] onInstalled:', reason));
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type !== 'links') return;
const tabId = sender.tab.id;
const text = String(msg.total);
chrome.action.setBadgeText({ tabId, text });
chrome.storage.session.set({ [`tab-${tabId}`]: msg }); // survives service worker restarts
console.log(`[sw] tab ${tabId}: ${msg.total} links, ${msg.external} external`);
sendResponse({ badge: text }); // or return true to reply later
});
[sw] onInstalled: install
[sw] tab 55599464: 7 links, 3 external
[content] badge = 7
9.15.3 Popups, Side Panels and Options Pages
The popup named by action.default_popup is an HTML page with full extension API access, destroyed whenever it loses focus. Extension pages block inline scripts by default, so its code lives in popup.js.<!doctype html>
<meta charset="utf-8">
<style>
body { width: 240px; padding: 0 16px; font: 14px system-ui, sans-serif; color: #37474f; }
h1 { font-size: 16px; color: #1565c0; } output { font-size: 32px; font-weight: 700; }
</style>
<h1>Link Counter</h1>
<p id="title">No data for this tab</p>
<output id="total">-</output>
<p id="external"></p>
<script src="popup.js"></script>
async function show(tabId) {
const key = `tab-${tabId}`;
const { [key]: data } = await chrome.storage.session.get(key);
if (!data) return;
document.querySelector('#title').textContent = data.title;
document.querySelector('#total').textContent = `${data.total} links`;
document.querySelector('#external').textContent = `${data.external} point to other sites`;
}
chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => show(tab.id));
Figure 9.22: The Link Counter popup after visiting a page with seven
links (screenshot: Chromium 151)A side panel stays open while the user browses: declare "side_panel":
{ "default_path": "panel.html" } plus the sidePanel permission (Chrome
114+), or sidebar_action in Firefox. Settings belong on an options page
(options_page opens a tab, options_ui embeds it in the extensions page),
stored with chrome.storage.sync so they follow the user's signed-in
browsers.9.15.4 Publishing to the Chrome Web Store, Firefox Add-ons and Edge Add-ons
All three stores take a ZIP of the same folder, with manifest.json at its root, plus icons, screenshots and a privacy disclosure. Request only the permissions you use: broad host patterns such as <all_urls> and powerful APIs such as tabs or webRequest draw closer review.9.15.5 Cross-browser Extensions with WXT and Plasmo
Hand-written folders stop scaling once you want TypeScript, npm packages and several target browsers. WXT (https://github.com/wxt-dev/wxt) (MIT, version 0.21.4 in August 2026) is built on Vite: files in entrypoints/ such as background.ts, content.ts and popup/index.html generate the manifest, the dev server reloads the extension as you edit, and one codebase builds Manifest V2 or V3 for Chrome, Firefox, Edge and Safari. Plasmo (https://github.com/PlasmoHQ/plasmo) (MIT) takes a React-first, Parcel-based approach with helpers for storage, messaging and content-script UIs, but its last npm release, 0.90.5, dates from May 2025 and its README still calls it alpha software, so WXT is the safer choice for new projects.npx wxt@latest init link-counter && cd link-counter && npm install
npm run dev # opens a browser with the extension loaded
npx wxt zip && npx wxt zip -b firefox # store-ready ZIPs in .output/