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>

    Listing 9.1: Probing context, features and permissions 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>

    Listing 9.2: XMLHttpRequest with events and a JSON response Figure 9.2: Browser output of Listing 9.2

    9.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

    Listing 9.3: Fetch with Request, Headers, error checks and a timeout In headless Chrome 152, against a small Node server, it logs:

    created: {id: 1, text: Buy milk}

    Error: HTTP 404 Not Found

    TimeoutError: signal timed out

    Output of Listing 9.3 controller.abort() rejects with AbortError, and AbortSignal.any() combines signals (Section 6.6.10). The priority hint is Baseline Newly available (since October 2024). keepalive: true lets up to 64 KiB of requests finish after the page closes; Firefox added it last, in version 133.

    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>

    Listing 9.4: A gzip round trip through a pipe chain 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' }));

    });

    Listing 9.5: Reporting time on page when the tab is hidden When headless Chrome 152 leaves the page, the Node server logs: POST /beacon type=application/json mode=cors body={"event":"leave","ms":1220} Output of Listing 9.5 A JSON Blob travels in cors mode, so a cross-origin collector must answer a preflight; a plain string does not. Chromium's fetchLater() sends automatically when the page goes away.

    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);

    Listing 9.6: Two peers and a data channel in one page Bob got "Hello, Bob!" via chat Output of Listing 9.6

    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>

    };

    Listing 9.7: Sending a camera to the other peer Browsers must implement VP8, H.264 and Opus, and encrypt media with SRTP. In a mesh call everyone uploads one copy per peer, so larger calls need a media server.

    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. Server Language, license Model Good fit Red5 Java, Apache-2.0 RTMP; paid Pro adds WebRTC Live broadcasts mediasoup C++/Node.js, ISC SFU library Custom apps Janus C, GPL-3.0 Plugin server Rooms, SIP gateways LiveKit Go, Apache-2.0 SFU, SDKs, cloud Rooms, AI agents Table 9.1: Open-source WebRTC media servers (GitHub, September 2026) mediasoup leaves signaling to you, while LiveKit's client (npm install livekit-client) reduces a call to new Room(), room.connect(url, token) and room.localParticipant.enableCameraAndMicrophone(), with your back end issuing the token.

    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);

    Listing 9.8: A WebSocket client

    message: echo: hello

    closed 1000 true

    Output of Listing 9.8 send() buffers without limit (watch bufferedAmount) and nothing reconnects for you. WebSocketStream, Chromium-only since version 124, exposes readable and writable streams with backpressure after await wss.opened. Socket.IO adds rooms and reconnection, but its protocol is not plain WebSocket.

    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...');

    Listing 9.9: Receiving named events with EventSource

    id 1: {"n":1}

    id 2: {"n":2}

    id 3: {"n":3}

    stream ended, reconnecting...

    id 4: {"n":4}

    Output of Listing 9.9 EventSource sends only plain GET requests, so clients of the OpenAI and Anthropic APIs, which stream answers to a POST as SSE, parse fetch() bodies instead. Over HTTP/1.1 each stream holds one of six connections per host.

    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');

    Listing 9.10: Subscribing, receiving and sending a push message Delete a stored subscription when the push service answers 404 or 410.

    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>

    Listing 9.11: Parsing, building and matching URLs 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]));

    Listing 9.12: A stream and a datagram over one WebTransport session WebSocket SSE WebTransport Direction Two-way Server to client Two-way Runs over TCP HTTP response HTTP/3 (QUIC) Unreliable mode No No Datagrams Baseline Widely (2018) Widely (2022) Newly (2026) Table 9.2: Persistent client-server channels compared Choose SSE for one-way feeds, WebSocket for messaging, WebTransport for lowest latency, and WebRTC between browsers.

    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);

    })()));

    Listing 9.13: A download that outlives the tab

    2000/3000 -

    3000/3000 -

    3000/3000 success

    Output of Listing 9.13 Both files reached the episodes cache in headless Chrome 152 over http://localhost. Support is Limited — Chrome 74 and Edge 79 upwards, nothing in Firefox or Safari — so guard on 'backgroundFetch' in reg and fall back to a plain fetch().

    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>

    Listing 9.14: A route table built from URL patterns 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. Mechanism Capacity API style Lifetime Workers Cookies About 4 KB each Sync or async Until expiry Service workers localStorage 5 MiB Sync, strings Until cleared No sessionStorage 5 MiB Sync, strings Until tab closes No IndexedDB Shared quota Async, any cloneable Cleared or evicted Yes Cache API Shared quota Async, responses Cleared or evicted Yes OPFS Shared quota Async or sync Cleared or evicted Yes Table 9.3: Client-side storage options compared

    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');

    Listing 9.15: document.cookie versus the Cookie Store API

    theme=dark; note=a%3Bb%3Dc

    change: cart

    3 strict

    change: theme

    Output of Listing 9.15 Store an identifier, not data, and mark session cookies HttpOnly; Secure; SameSite=Lax.

    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);

    }

    Listing 9.16: Storing strings, numbers and objects in Web Storage

    51 20

    QuotaExceededError

    Output of Listing 9.16 Other same-origin tabs get a storage event (key, oldValue, newValue) when localStorage changes, handy for syncing a logout. The API is synchronous and absent from workers: keep it for small settings, never tokens, and put large data in IndexedDB.

    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(', '));

    Listing 9.17: An indexed student database with idb Paul 3A, Bill 4H Output of Listing 9.17 Dexie.js (https://github.com/dexie/Dexie.js) (4.4.6, Apache-2.0, npm install dexie) declares the schema as a string, db.version(1).stores({ students: 'id, clss, age' }), queries fluently, db.students.where('age').below(11).sortBy('name'), and offers a useLiveQuery() React hook. For plain get and set, idb-keyval (https://github.com/jakearchibald/idb-keyval) (6.3.0) is enough.Beware TransactionInactiveError: awaiting a non-IndexedDB promise such as fetch() lets the transaction commit, so fetch first, then write. Close the connection on versionchange, or another tab's upgrade stays blocked.

    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);

    }

    Listing 9.18: A cache-first JSON loader

    1 network 3

    2 cache 3

    Output of Listing 9.18 match() treats another query string, method or Vary header as a different request unless told to ignore it (ignoreSearch and similar options). Version cache names, list them with caches.keys() and caches.delete() old ones on deploy; Section 9.4.4 builds offline support on this pattern inside a service worker.

    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

    Listing 9.19: A minimal router with the Navigation API

    push /app/products

    push /app/cart

    traverse /app/products

    push /app/help

    Output of Listing 9.19 When the handler's promise settles, the browser resets focus and restores or sets the scroll position (opt out with focusReset: 'manual' and scroll: 'manual'); pass event.signal to fetch() so a stopped or superseded navigation cancels its requests. Keep a History API fallback for older browsers.

    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>

    Listing 9.20: Checking and reading a chosen file 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('/'));

    Listing 9.21: Writing and reading a file in the origin private file system 12 "1. Buy milk\n" notes/todo.txt Output of Listing 9.21

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

    Listing 9.22: Measuring usage and requesting persistent storage

    3.0 MiB of 10243.0 MiB {"caches":3145815}

    false false

    Output of Listing 9.22 Quotas vary: Chrome allows an origin up to 60% of the disk, Firefox the smaller of 10% of the disk or 10 GiB per site (up to 50% of the disk once persistent), and Safari about 60%. Check estimate() before large downloads, catch QuotaExceededError, and request persistence only for data the user cannot download again.

    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));

    };

    Listing 9.23: An embedded widget asking for its own cookies

    cookie: "" | permission: prompt

    denied: NotAllowedError

    Output of Listing 9.23 In headless Chrome 152 with third-party cookies blocked, the cookie was invisible and the request failed with nobody to answer the prompt. Storage Access Headers skip the reload a grant normally forces: a request carries Sec-Fetch-Storage-Access: inactive and the server replies Activate-Storage-Access: retry; allowed-origin=* (Chrome 133, Firefox 147, not Safari). CHIPS (Section 9.3.1) needs no permission but gives a separate jar per embedding site: use it for per-embed state, this API for one shared identity.

    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). Dedicated Shared Service Constructor Worker SharedWorker register() Serves One page Same-origin pages A URL scope Lifetime Its page Its last page Event-driven Baseline Widely (2015) Newly (2026) Widely (2018) Table 9.4: The three kinds of worker

    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');

    Listing 9.24: fib-worker.js and the page script that starts it

    the page stays responsive meanwhile

    fib(40) = 102334155

    Output of Listing 9.24 Size a small worker pool by navigator.hardwareConcurrency, and transfer big ArrayBuffers with postMessage(buf, [buf]) instead of copying them. Comlink (https://github.com/GoogleChromeLabs/comlink) (Apache-2.0) wraps a worker in a Proxy, turning messages into async calls.

    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));

    Listing 9.25: counter.js and the page script, opened as #Tab-A and #Tab-B

    Tab-A is client #1 of 1

    Tab-B is client #2 of 2

    Output of Listing 9.25 The worker is never told that a tab closed, so send a goodbye message on pagehide and drop that port.

    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

    Listing 9.26: sw.js and the page script: taking control and exchanging messages

    via port: question #2

    via client: ping #1

    Output of Listing 9.26 Without clients.claim(), a new worker controls the page only from its next load. Use skipWaiting() with care: open tabs switch to a worker that may not match their cached files.

    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'))));

    });

    Listing 9.27: sw.js: precaching and a cache-first fetch handler Offline in headless Chrome, a reload still worked and an uncached URL returned offline.html. For versioning, runtime caching and expiry, most projects use Google's Workbox (https://github.com/GoogleChrome/workbox) (MIT, version 7.4.1). Given a config with globDirectory: 'dist/' and runtimeCaching rules (CacheFirst, NetworkFirst, StaleWhileRevalidate), npx workbox-cli generateSW generated a sw.js that precaches every file with a revision hash, so only changed files download again. Vite projects use vite-plugin-pwa.

    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 ?? '/'));

    });

    Listing 9.28: sw.js: showing pushed messages and handling clicks Call Notification.requestPermission() only from a user action, such as a click on a "Notify me" button, and never on page load.

    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

    });

    Listing 9.29: Registering sync (page) and handling it (sw.js) In headless Chrome the sync event fired at once (it was online) and periodic registration rejected with NotAllowedError. Safari and Firefox lack both, so also flush the queue on load and on online.

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

    });

    Listing 9.30: sw.js: enabling and using navigation preload reg.navigationPreload.getState() then reported {"enabled":true,"headerValue":"true"}. Skip it if navigations are served from the cache.

    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);

    Listing 9.31: Page script opened in tabs B and C, then A (A posts after 500 ms)

    #tab-C got {"type":"logout","at":"2026-09-15T16:43:13.362Z"}

    #tab-B got {"type":"logout","at":"2026-09-15T16:43:13.362Z"}

    Output of Listing 9.31 Messages are structured clones, so functions cannot be sent. Call channel.close() when a component unmounts.

    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 });

    Listing 9.32: Draining a work queue in idle periods, with a timer fallback 4000 items in 8 idle periods Output of Listing 9.32 Make DOM changes in requestAnimationFrame(), not here.

    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>

    Listing 9.33: Finding the script behind a janky frame

    frame 168 ms, blocking 117.3 ms

    BUTTON#sort.onclick -> sortRows

    Output of Listing 9.33 In production, beacon these entries to analytics and group them by sourceURL, then fix the worst scripts with a worker or by yielding (Section 9.4.12).

    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));

    Listing 9.34: Queued and conditional lock requests

    C: null

    A holds drafts-db (exclusive); 1 waiting

    B holds drafts-db (exclusive); 0 waiting

    Output of Listing 9.34 A callback that never settles blocks all other tabs, so keep locked sections short.

    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');

    Listing 9.35: Task priorities and a chunked job that yields

    user-blocking task

    20 chunks done

    user-visible task

    background task

    Output of Listing 9.35 The yielding loop still beat the queued user-visible task. Yield at least every 50 ms to keep Interaction to Next Paint low.

    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" }

    ]

    }

    Listing 9.36: app.webmanifest Two more manifest members make an installed app behave less like a tab. "launch_handler": { "client_mode": "focus-existing" } (Chromium 110) stops a second window from opening on relaunch, and window.launchQueue.setConsumer() receives the target URL so you can route to it yourself. "display_override": ["window-controls-overlay"] (Chromium 105) hands the title-bar strip to your markup, laid out with env(titlebar-area-x) and env(titlebar-area-width). Both are Chromium-only.Chrome's DevTools Protocol method Page.getInstallabilityErrors found no errors here; a manifest without start_url and icons, set to "display": "browser", produced start-url-not-valid, manifest-display-not-supported and icon errors (also listed in the Application panel, Section 1.3.4). For a custom Install button in Chromium, save the beforeinstallprompt event and call its prompt() on click. The media query (display-mode: standalone) matches inside the installed app.

    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);

    Listing 9.37: A private two-way pipe between the page and a worker

    running total 5

    running total 15

    running total 35

    Output of Listing 9.37 Assigning onmessage implicitly calls port.start(); with addEventListener you must call it yourself or nothing is delivered. A transferred port is detached in the sender. Baseline Widely available since Firefox 41 shipped it in 2015.

    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);

    Listing 9.38: Saving on hidden, and reacting to freeze, resume and discard visibilitychange is not an unload hook — it also fires when the user merely glances at another tab; for "really going away" use pagehide, which pairs with the back/forward cache (Section 9.10). Page Visibility is Baseline Widely available; freeze, resume and wasDiscarded are Chromium-only (Chrome and Edge since 2018), so treat them as an optimization. Headless Chrome keeps every page visible, so the hidden branch needs a real tab switch.

    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>

    Listing 9.39: A task board; the last three lines fake one drag for the screenshot 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'));

    };

    Listing 9.40: Copying text and a rich HTML item from a click handler text/plain,text/html true Output of Listing 9.40 Chromium asks for the clipboard-read permission before reading; Firefox and Safari show a Paste menu the user must choose. Test formats beyond text/plain, text/html and image/png with ClipboardItem.supports(). To change what Ctrl+C copies, handle the copy event and set event.clipboardData.

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

    };

    Listing 9.41: Reading the screen, then going full screen from a click

    1280x720 landscape-primary

    fullscreen: HTML

    lock: NotSupportedError

    Output of Listing 9.41 Fullscreen is Limited availability: Safari on iPhone lacks it. The Chromium-only Window Management API handles multiple monitors: after a permission prompt, getScreenDetails() lists each screen's position and label, so slides can requestFullscreen({ screen }) onto the projector while notes stay on the laptop.

    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

    };

    Listing 9.42: User-Agent Client Hints, then sharing and badging from a click

    Chromium 152, Not?A_Brand 24, Google Chrome 152 Windows

    x86 152.0.7977.78

    Output of Listing 9.42 The fake Not?A_Brand stops sites matching exact brand lists. share() opens the system share sheet (all but Firefox desktop), and setAppBadge() badges installed apps (Section 9.4.13) in desktop Chromium and Safari 17+.

    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])));

    Listing 9.43: A legacy encoding, encodeInto() and a streaming decode

    Привет!

    {read: 3, written: 4}

    €

    Output of Listing 9.43 Without { stream: true }, each chunk boundary would produce U+FFFD. For fetched bodies, use response.body.pipeThrough(new TextDecoderStream()) (Section 9.2.3).

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

    Listing 9.44: Querying a menu with XPath, then transforming it with XSLT

    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

    Output of Listing 9.44 Chromium, Gecko and WebKit all plan to remove XSLT, whose aging libxslt C code keeps producing security bugs. Chrome 158 disables it on Stable, apart from an origin trial and enterprise policy lasting until Chrome 176. Transform on the server or in JavaScript, or load the WASM xslt_polyfill (https://github.com/mfreed7/xslt_polyfill). XPath stays.

    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>

    Listing 9.45: The quadratic formula in native MathML 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>

    Listing 9.46: One keyframe animation paused at 0, 500 and 1000 ms 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(); };

    };

    Listing 9.47: Requesting permission and showing a notification from a button new Notification() has Limited availability: Chrome for Android always throws, and iOS allows it only in Home Screen web apps. The Widely available route is registration.showNotification() in a service worker (Section 9.4.5), which adds action buttons and pairs with push (Section 9.2.10).

    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);

    Listing 9.48: A typed transition that drives its own animation once ready

    forward: true active: true

    Page 2 finished, active: null

    Output of Listing 9.48 pseudoElement lets the Web Animations API (Section 9.5.8) animate with values computed in script. Types and activeViewTransition reached Firefox 147; still guard with if (!document.startViewTransition).

    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>

    Listing 9.49: Opening a popover from script and logging its toggle events 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);

    };

    Listing 9.50: Video PiP, a Document PiP player window, and casting

    device: false

    PiP window 284x160

    doc PiP opened; same origin: true

    Output of Listing 9.50 Every entry point needs transient activation, so none of it runs unattended; that output is headless Chrome 152 driven by scripted trusted clicks, where no cast device exists and the button hides itself. A PiP window starts with no CSS, so clone your <link rel="stylesheet"> elements into win.document.head, and move the player back on pagehide. Video PiP is in Chrome 69+, Safari 13.1+ and Firefox 153 (July 2026), short of Baseline only because Firefox for Android lacks it; Document PiP is desktop Chrome and Edge 116+ and Firefox 151+; Remote Playback is Chrome and Edge 121+ and Safari 13.1+, neither of them in Firefox.

    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));

    Listing 9.51: Building a text-fragment link and what Chrome reported after following it

    http://localhost:8000/notes.html#:~:text=renderer%20builds,-a%20layout

    [object FragmentDirective] "" 820

    Output of Listing 9.51 Scroll-to-text is Baseline since Firefox 131 (Chrome 80, Safari 16.1); ::target-text needs Safari 18.2.For matches you compute yourself — search hits, spell-checking, a collaborator's cursor — use the CSS Custom Highlight API instead: CSS.highlights.set('hit', new Highlight(...ranges)) paints Range objects through ::highlight(hit) without inserting <mark> elements (Section 8.3.6). highlight.priority settles overlaps, only color, background-color, text-decoration, text-shadow and -webkit-text-stroke apply, and ranges store node offsets, so recompute them when the text changes. Chrome 105, Safari 17.2, Firefox 149.

    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. API Entry point Support (September 2026) Media capture mediaDevices.getUserMedia() Widely available (2017) Image Capture new ImageCapture(track) Chromium, Safari 18.4; no Firefox MediaStream Recording new MediaRecorder(stream) Widely available (2021) Screen Capture getDisplayMedia() Desktop only Media Capabilities mediaCapabilities.decodingInfo() Widely available (2022) Media Session navigator.mediaSession All engines; Limited Encrypted Media Extensions requestMediaKeySystemAccess() Widely available (2019) Media Source Extensions new MediaSource() Limited: iPhone needs ManagedMediaSource WebVTT <track>, VTTCue Widely available (2015) Speech synthesis speechSynthesis.speak() Widely available (2018) Speech recognition SpeechRecognition Chromium; Safari prefixed Web MIDI requestMIDIAccess() Chromium, Firefox; no Safari Web Audio new AudioContext() Widely available (2021) WebCodecs VideoEncoder, AudioDecoder Chromium, Firefox, Safari 26 Table 9.5: Media APIs and their Baseline status (webstatus.dev and MDN)

    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`);

    Listing 9.52: Opening the camera and taking a photo

    fake_device_0: 1280x720 @ 20 fps

    photo: image/png, 5617 bytes

    Output of Listing 9.52 Without ImageCapture (Firefox), draw the video onto a canvas. Call track.stop() when done, or the camera light stays on.

    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

    Listing 9.53: Recording three seconds of camera and microphone into a playable Blob The timeslice lets you upload chunks while recording. Chrome's WebM files carry no duration (<video> reports Infinity), so remux them before seeking, for example with ffmpeg -i in.webm -c copy out.webm.

    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 }));

    }

    Listing 9.54: Asking which 1080p codec decodes efficiently on this device

    H.264 {"supported":true,"smooth":true,"powerEfficient":true}

    VP9 {"supported":true,"smooth":true,"powerEfficient":true}

    AV1 {"supported":true,"smooth":true,"powerEfficient":false}

    Output of Listing 9.54 This laptop lacks an AV1 hardware decoder, so VP9 or H.264 is the better choice here despite AV1's smaller files.

    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

    Listing 9.55: Appending an init segment and five media segments with fetch() iPhone Safari offers only ManagedMediaSource (iOS 17.1+), which may evict buffered data to save memory and fires startstreaming and endstreaming to say when to download.

    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. Library Formats Version, licence Notes hls.js HLS 1.7.3, Apache-2.0 Transmuxes MPEG-TS to fMP4 in a worker dash.js DASH 5.2.1, BSD-3-Clause DASH Industry Forum reference player Shaka Player DASH, HLS 5.2.10, Apache-2.0 Offline storage in IndexedDB Table 9.6: Open-source adaptive streaming players (npm, September 2026) With hls.js (https://github.com/video-dev/hls.js) (npm i hls.js), call hls.loadSource(url) and hls.attachMedia(video) after checking Hls.isSupported(), but let Safari play HLS natively when video.canPlayType('application/vnd.apple.mpegurl') succeeds. With dash.js (https://github.com/Dash-Industry-Forum/dash.js) (npm i dashjs), dashjs.MediaPlayer().create().initialize(video, 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd', true) starts Big Buck Bunny with autoplay.

    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 }));

    };

    Listing 9.56: Dictating text, then reading it back aloud In headless Chrome 152 on Windows, available() returned 'downloadable' (a language pack can be installed) and the voices were Microsoft David, Mark and Zira. Grammars (SpeechGrammarList) were removed from the specification and do nothing; bias recognition toward names and jargon with rec.phrases.

    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 listing

    const 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

    Listing 9.57: Rendering a filtered, enveloped, panned note offline and measuring its peaks For live sound use new AudioContext() and resume() it in a click, since autoplay policies start it suspended. Write custom DSP as an AudioWorkletProcessor, not the deprecated ScriptProcessorNode.

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

    };

    Listing 9.58: A reverb-soaked synth arpeggio started from a button The nested array plays two notes in one step. Callbacks fire imprecisely, so always pass their time argument on.

    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

    Listing 9.59: Encoding 60 canvas frames to VP9 Check VideoEncoder.isConfigSupported(config) first and run heavy jobs in a worker. Chunks are raw codec data: wrap them in MP4 or WebM with Mediabunny (https://github.com/Vanilagy/mediabunny) (npm i mediabunny, MPL-2.0).

    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

    Listing 9.60: Listing audio outputs and routing an element to one of them

    default "Default - Speakers (2- High Definition Audio Device)"

    8e1b8565ca064a "Digital Output (2- High Definition Audio Device)"

    Output of Listing 9.60 setSinkId() is Chrome 49, Firefox 116 and Safari 18.4, desktop only — Chrome for Android cannot switch outputs — and the speaker-selection permissions policy can block it (Section 9.8.5). Firefox alone offers selectAudioOutput(), a native picker that grants one device; elsewhere you build the menu yourself.The Audio Session API handles mixing: navigator.audioSession.type set to playback pauses other apps' media, transient plays a notification over them, transient-solo interrupts them for a voice prompt, ambient mixes freely and obeys the mute switch, and play-and-record covers calls. Safari 16.4 and later only, so treat it as an enhancement alongside Media Session (Section 9.6.5).

    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. API Baseline Chrome Firefox Safari Pointer events Widely 55 59 13 Touch events Limited 22 52 (touch screens) iOS only Permissions Widely 44 46 16 Geolocation Widely 5 3.5 5 deviceorientation Widely 31 6 17 Gamepad Widely 35 29 10.1 Screen Wake Lock Newly (2025) 84 126 16.4 Pointer Lock Limited 37 50 10.1, not iOS Accelerometer, Gyroscope Limited 67 No No Keyboard map and lock Limited 69, 68 No No Vibration Limited 32 (Android) Removed in 129 No Presentation Limited 47 No No Web NFC Limited Android 89 No No Web Bluetooth Limited 70 No No WebUSB, WebHID Limited 61, 89 No No Web Serial Limited 89 151 (add-on) No Battery, Device Memory Limited 38, 63 No No Compute Pressure Limited 125 (desktop) No No Table 9.7: Device API support by first stable version (webstatus.dev and MDN, September 2026)

    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);

    };

    Listing 9.61: Scaling a photo with a two-finger pinch Give the photo touch-action: none so the browser does not zoom the page as well (Section 8.7.9).

    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); }

    }

    Listing 9.62: Querying device permissions, including a name Chrome rejects

    geolocation prompt

    accelerometer granted

    bluetooth TypeError

    Output of Listing 9.62 Wrap query() in try, and listen for the status object's change event to update your UI.

    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

    }

    Listing 9.63: Promise-based geolocation (headless Chrome with an emulated position) 5.9804, 116.0735 ±20 m, speed null Output of Listing 9.63 altitude, heading and speed stay null without GPS. For an address, call a geocoding service such as OpenStreetMap's Nominatim. Chrome 144 added a declarative <geolocation> element: a browser-drawn button that also lets users undo an earlier denial.

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

    Listing 9.64: Tilt control with a permission step, plus the Chromium Accelerometer Headless Chrome has no sensors and logs NotAllowedError; test on a phone or fake values in the DevTools Sensors panel.

    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']);

    Listing 9.65: Labeling keys for the user's layout and capturing reserved keys Move with W A S D (48 keys mapped) Output of Listing 9.65 On a French AZERTY keyboard the same code prints Z Q S D.

    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);

    })();

    Listing 9.66: Polling a controller in a game loop Rumble works in Chrome and Safari 16.4+.

    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}`));

    }

    Listing 9.67: One handler for mouse, pen and touch

    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

    Output of Listing 9.67 Mice without pressure hardware report 0.5 while pressed. Without touch-action: none the browser may start scrolling and fire pointercancel. Drawing apps call e.getCoalescedEvents() to recover samples merged into one frame (Safari since 18.2).

    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);

    });

    Listing 9.68: Mouse-look for a 3D scene unadjustedMovement arrived in Chrome 88, Safari 18.4 and Firefox 152.

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

    });

    Listing 9.69: Keeping the screen on while a recipe is open Call lock.release() when the task ends. Wrap request() in try: it rejects in power-saving mode.

    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' }] });

    Listing 9.70: Reading and writing an NFC tag on Android

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

    };

    Listing 9.71: Sending a line to an Arduino over Web Serial

    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

    Listing 9.72: Reading battery, memory, CPU tier and CPU pressure

    1 true 32 4

    cpu nominal

    Output of Listing 9.72

    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 }]

    Listing 9.73: The three permission-gated calls (each requires a click and a prompt) API Entry point Support (September 2026) Contact Picker navigator.contacts.select() Chrome for Android 80; no desktop build Idle Detection new IdleDetector() Chrome 94, Edge 114; Mozilla objects Local Font Access window.queryLocalFonts() Chrome 103, Edge 103, desktop only Table 9.8: Availability of the three OS-facing device APIs Feature-detect and keep a fallback: a typed phone number, a visibilitychange listener, and web fonts (Section 4.5.10) in place of the local font list.

    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');

    };

    Listing 9.74: Probing payment methods, then paying from a click

    basic-card false

    https://google.com/pay true

    Output of Listing 9.74 canMakePayment() means the browser handles a method, not that the user has a card. Wallets need their own data, so most shops embed a processor component such as Stripe's Express Checkout Element.

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

    Listing 9.75: Silent sign-in with a saved password, storing it, and signing out Firefox and Safari save the same form through autofill, so the API is an enhancement.

    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 server

    const 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);

    }

    Listing 9.76: Registering a passkey, then offering it in username autofill Let SimpleWebAuthn (https://github.com/MasterKale/SimpleWebAuthn) (MIT, npm install @simplewebauthn/server) build options (residentKey: 'required' makes a passkey) and verify responses. A person must approve each ceremony, so this cannot run headless. Autofill sign-in works in Chrome 108+, Safari 16.1+ and Firefox 122+ on desktop (passkeys.dev (https://passkeys.dev/device-support/)). When you delete a credential, PublicKeyCredential.signalUnknownCredential() (Chrome 132, Safari 26) tells password managers.

    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')));

    Listing 9.77: Password-based AES-GCM encryption and an Ed25519 signature

    25 bytes sealed -> Meet at 9

    64 byte signature valid: true

    Output of Listing 9.77 The extra 16 bytes are AES-GCM's tag, which makes decrypt() reject tampering. Ed25519 reached all engines with Chrome 137 (May 2025). Use OWASP's 600,000 PBKDF2 iterations and never reuse an IV with the same key.

    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>

    Listing 9.78: Querying the policy and catching a violation in a restricted frame 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>

    Listing 9.79: Violation events for CSP and Trusted Types, and a configured setHTML() 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>

    Listing 9.80: A page served from localhost with COOP and COEP; 127.0.0.1 is another origin

    isolated: true, no-CORP image: false

    CORP image: true

    worker wrote 42

    Output of Listing 9.80 Isolation also enables WebAssembly threads. COEP credentialless (Chrome 96, Firefox 119) loads third-party resources without cookies instead of blocking them; Chrome 137's Document-Isolation-Policy isolates one document without either header. COOP breaks OAuth popups that use window.opener unless you use same-origin-allow-popups.

    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". API What it did Status Topics Interest labels from history Deprecate and remove Protected Audience On-device ad auctions Deprecate and remove Attribution Reporting Aggregated conversions Deprecate and remove Shared Storage, selectURL Cross-site writes Deprecate and remove Fenced Frames Embedder-proof frame Scheduled for phaseout Private State Tokens Anti-fraud signals Continue to support Table 9.9: Privacy Sandbox APIs and their status in September 2026 What survives was never about advertising: partitioned cookies (CHIPS, Section 9.3.1), the Storage Access API (Section 9.3.9), FedCM (Section 9.8.2), Private State Tokens, and storage and network partitioning. Related Website Sets went with the ad APIs, so document.requestStorageAccessFor() is going too. Your instruction is short: write nothing new against the retired APIs, and delete inherited <fencedframe>, joinAdInterestGroup() or attributionsrc code. All of them outlive the announcement, so an in check proves nothing.

    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); }

    Listing 9.81: Probing the Privacy Sandbox surface

    Topics, Protected Audience, Shared Storage, Fenced Frames: true true true true

    call: NotSupportedError

    Output of Listing 9.81 Headless Chrome 152 exposes every one of them, yet browsingTopics() refuses to run outside an ad context allowed by the browsing-topics permission policy. No engine outside Chromium shipped these APIs; Firefox and Safari partition or block third-party cookies instead (Total Cookie Protection and ITP). Build for that world: first-party data, partitioned storage, and passkeys or FedCM for identity.

    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

    }

    }

    Listing 9.82: Filling a one-time-code field from an SMS There is no output to show: the call needs a real SIM, a real message and a tap on the browser's prompt, none of which a headless run can supply. Test it on a device, or through ADB port forwarding to localhost.The autocomplete="one-time-code" attribute the code queries for is the half that works everywhere: Safari, iOS and Android autofill offer the digits above the keyboard from this exact message format, so ship the attribute first and treat the API as the enhancement. WebOTP is Baseline limited — Chrome for Android 84, Chrome and Edge 93 on desktop, which read the message from a signed-in Android phone; Firefox and Safari have no OTPCredential. It needs a secure context and a top-level document, and it shortens the typing, not the risk: SMS codes are phishable, so prefer passkeys (Section 9.8.3) for account access.

    9.9 Observers

    Observers replace polling and scroll or resize handlers with batched callbacks. All have observe() and disconnect(). Observer Reports Status MutationObserver DOM changes (Section 8.6.4) Widely available (2015) ResizeObserver Element size changes Widely available (2020) IntersectionObserver Visibility in a viewport or scroller Widely available (2019) PerformanceObserver Performance timeline entries All engines ReportingObserver Deprecations, interventions, violations Newly available (2026) Table 9.10: The observer family

    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>

    Listing 9.83: Catching a deprecation report

    deprecation: XMLHttpRequestSynchronousInNonWorkerOutsideBeforeUnload

    "Synchronous `XMLHttpRequest` on the main thread is deprecated ..."

    Output of Listing 9.83 Forward them with navigator.sendBeacon() (Section 9.2.4). crash reports go only to a default endpoint in Reporting-Endpoints.

    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>

    Listing 9.84: A component that adapts to its own width 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>

    Listing 9.85: Which rows of a scrolled list count as visible, with a 30px margin 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>

    Listing 9.86: LCP and User Timing entries as they happen

    mark build-start 359 0

    measure build-report 359 35

    largest-contentful-paint h1 424 0

    Output of Listing 9.86 The blocking loop pushed LCP back. The old performance.timing is deprecated in favor of the navigation entry; Section 9.10 covers Web Vitals.

    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). Entry types (API) Measures Support mark, measure (User Timing) Your own named spans Widely available navigation, resource DNS, TLS, TTFB, load phases Widely available paint and Server-Timing FCP; server-sent durations Widely available largest-contentful-paint Largest element painted Newly (Dec 2025) event, first-input Input latency per event Newly (Dec 2025) layout-shift, long-animation-frame Shifts; slow frames (9.4.10) Chromium only Table 9.11: Performance timeline entry types Google condenses page experience into three Core Web Vitals, judged at the 75th percentile of page loads (thresholds in Section 1.3.3). LCP is the render time of the largest image or text block, updated as larger elements paint until the user taps, scrolls or types; Chromium skips invisible elements, full-viewport backgrounds and low-entropy placeholder images. INP, which replaced First Input Delay on March 12, 2024, is the slowest interaction from input to next frame, ignoring the single worst one per 50 interactions. CLS adds up layout-shift scores (impact fraction times distance fraction) in bursts of shifts less than 1 s apart and at most 5 s long, and reports the worst burst.Getting the edge cases right (hidden tabs, bfcache restores, events belonging to one interaction) is fiddly, so use Google's web-vitals library (github.com/GoogleChrome/web-vitals (https://github.com/GoogleChrome/web-vitals), Apache 2.0, about 3 KB brotli, npm install web-vitals). Version 6.2.2 exports onLCP, onINP, onCLS, onFCP and onTTFB; version 6 added reportSoftNavs: true for single-page-app route changes in Chromium 151+. Below, the test server delays hero.svg by 800 ms, an ad lands after 1 s without reserved space, and the click handler blocks for 250 ms.

    <!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>

    Listing 9.87: Provoking and measuring LCP, CLS and INP

    LCP 296 good

    CLS 0 good

    TTFB 61.8 good

    LCP 912 good

    CLS 0.147 needs-improvement

    measure 250

    INP 256 needs-improvement

    Output of Listing 9.87 Headless Chrome 152 (1280x720, scripted click at 1.5 s) reports LCP for the heading, then the hero. Callbacks receive { name, value, rating, delta, id, entries }. In production drop reportAllChanges and send each metric with navigator.sendBeacon() (Section 9.2.4); import web-vitals/attribution to learn the culprit: the LCP element, the most-shifted node, or the INP target with its long-animation-frame scripts.To learn which of your functions are slow on real devices, the JS Self-Profiling API samples the stack from inside the page: serve the document with Document-Policy: js-profiling, then new Profiler({ sampleInterval: 10, maxBufferSize: 10_000 }) records and await profiler.stop() resolves to a trace of frames, stacks and samples to ship with the metric. Chromium 94+ only, and sampling costs time, so profile a fraction of visits.The bfcache freezes a whole page in memory when you leave, so Back restores it instantly. All major browsers have one (Chrome since 96). A restore fires pageshow with persisted === true instead of load; notRestoredReasons (Chromium 125+) explains failures.

    <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>

    Listing 9.88: Detecting bfcache restores and blocking reasons

    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

    Output of Listing 9.88 The last three lines are the same visit with ?block. Chrome 152 kept pages with an open IndexedDB connection, a BroadcastChannel or a held Web Lock, but one empty unload listener evicted it. masked means the browser withheld the reason; others include unload-listener, response-cache-control-no-store and websocket. Use pagehide instead of unload, and check Application > Back/forward cache in DevTools.Speculation Rules (Section 2.13) let Chromium prerender the next page. Until activation, document.prerendering is true; afterwards the navigation entry's activationStart (Chromium 108+) marks when the user saw it. web-vitals subtracts it from LCP and TTFB, so prerendered visits report near-zero LCP; delay analytics until the prerenderingchange event.

    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. API Use it for Support Libraries Canvas 2D Charts, 2D games Widely available Chart.js, Konva SVG DOM Scalable vector shapes Widely available D3.js, GSAP WebGL 2 GPU 3D (OpenGL ES 3.0) Widely available three.js, Babylon.js WebGPU Rendering, GPU compute Chrome 113+, Safari 26 three.js, Babylon.js WebAssembly Compiled C, C++, Rust Widely available Emscripten, wasm-pack OffscreenCanvas Drawing in a worker Widely available three.js WebXR VR and AR sessions Chromium only A-Frame Table 9.12: Choosing a graphics or compute API Firefox 141 added WebGPU on Windows. The demo spells out a WebAssembly module byte by byte: the \0asm header, then sections for addTwo (local.get 0, local.get 1, i32.add). Real modules come from Emscripten, wasm-pack or Go and load with WebAssembly.instantiateStreaming(fetch('app.wasm')), which requires the application/wasm MIME type.

    <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>

    Listing 9.89: Probing the graphics and compute APIs 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. API What it does Status (September 2026) Prompt API (LanguageModel) Prompts with JSON-schema output Stable in Chrome 148 desktop Summarizer, Translator, Language Detector Task-specific on-device models Stable in Chrome 138 desktop Writer, Rewriter, Proofreader Draft, rephrase and correct text Developer trial (behind a flag) WebNN (navigator.ml) Neural nets on CPU, GPU, NPU Origin trial since Chrome 146 Digital Credentials ID documents from wallet apps Chrome 141, Safari 26 Web Share (navigator.share()) The OS share sheet Chrome, Safari; Firefox flag EyeDropper Pick a color anywhere on screen Chromium desktop only (95+) Isolated Web Apps Signed, installable app bundles Managed ChromeOS devices only Table 9.13: Built-in AI and emerging APIs Each AI API follows one pattern: availability() returns unavailable, downloadable, downloading or available, and create(), called from a user gesture, reports download progress and returns an object with batch and streaming methods.

    <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>

    Listing 9.90: On-device summarizing with feature detection and a server fallback

    Summarizer unavailable

    Translator downloadable

    Output of Listing 9.90 Headless Chrome 152 has the constructors but no model service. The Prompt API is analogous: LanguageModel.create(), then session.prompt(text, { responseConstraint: jsonSchema }); its sampling controls are in a separate origin trial.The rest need a user gesture. new EyeDropper().open() resolves to { sRGBHex }. navigator.share({ title, url }) needs HTTPS and transient activation; keep a copy-link fallback. navigator.credentials.get({ digital: { requests } }) asks a wallet for an ID over OpenID4VP or ISO 18013-7 Annex C. Isolated Web Apps are signed Web Bundles with isolated-app:// origins that unlock Direct Sockets and Controlled Frame, installed by admins on managed Chromebooks.

    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} &rarr; ${JSON.stringify(groups)}`);

    </script>

    Listing 9.91: Conditionally loading core-js from a pinned, hashed URL 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" }]]

    }

    Listing 9.92: babel.config.json: inject only the core-js modules the code uses

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

    Listing 9.93: app.js, compiled with Babel 8.0.5

    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";

    Output of Listing 9.93 The four imports precede the unchanged code. Browserslist now resolves baseline widely available to Chrome 121, Firefox 123 and Safari 17.4, so toSorted() needs nothing while iterator helpers (Safari 18.4) do. Object.groupBy is injected even though Safari 17.4 has it, because core-js-compat trusts only Safari 18.0 and later. In libraries, use "method": "usage-pure" so you never patch your users' globals.Modernizr (github.com/Modernizr/Modernizr (https://github.com/Modernizr/Modernizr), MIT, about 25,700 stars), started in 2009, runs feature tests and exposes the results on a global Modernizr object and as <html> classes such as webp or no-webp. Its last release, 3.13.1, is from August 2024, and its README warns that the modernizr.com builder is outdated; build from npm with modernizr.build(config, callback). @supports, CSS.supports() and in checks now cover most needs; Modernizr still helps where detection needs a real test, such as decoding a tiny WebP or AVIF image.The web-features package (Apache 2.0, version 3.38.0) holds the data behind every Baseline badge, so scripts can query it.

    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 ?? '-');

    }

    Listing 9.94: Querying Baseline status with web-features

    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 -

    Output of Listing 9.94 Enforce the policy in CI with baseline-js/use-baseline (eslint-plugin-baseline-js), the use-baseline rule of @eslint/css, and stylelint-plugin-use-baseline. When a feature turns Widely available, as array grouping did on September 5, 2026, delete its polyfill.

    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. Service Free tier Paid tiers reCAPTCHA (Google Cloud) 10,000 assessments/month $8 up to 100,000; then $1 per 1,000 hCaptcha Basic plan Pro from $99/month Cloudflare Turnstile Unlimited, 20 widgets Enterprise (quote) Table 9.14: Bot-protection prices in September 2026

    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']);

    Listing 9.95: Verifying a Turnstile token in an Express route Turnstile tokens expire after five minutes and validate once, and it runs on any site, proxied by Cloudflare or not. hCaptcha mirrors reCAPTCHA's API, so switching is mostly a change of script URL and keys.

    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. Gateway (US) Card or checkout rate Cross-border extra Stripe 2.9% + 30¢ +1.5% card, +1% conversion PayPal 3.49% + $0.49 (PayPal Checkout) +1.50% Table 9.15: Published US online processing fees, September 2026

    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' });

    Listing 9.96: Consent mode v2: deny by default (before the GA4 tag), grant on acceptance The MIT-licensed CookieConsent (https://github.com/orestbida/cookieconsent) library (npm i vanilla-cookieconsent) supplies the banner, per-category toggles and a callback for the update call.

    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"]

    }

    Listing 9.97: link-counter/manifest.json permissions unlocks APIs, while URL patterns in host_permissions or content-script matches grant site access and trigger the install warning, so ask for as little as possible. Firefox has no extension service workers and runs background.scripts instead; Chrome 121 and later ignore that key, so listing both works everywhere. All code must ship inside the package, because Manifest V3 forbids remotely hosted code. To try the extension, click Load unpacked on chrome://extensions with Developer mode on, or use about:debugging in Firefox.

    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 them

    const 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));

    Listing 9.98: link-counter/content.js

    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

    });

    Listing 9.99: link-counter/background.js

    [sw] onInstalled: install

    [sw] tab 55599464: 7 links, 3 external

    [content] badge = 7

    Output of Listing 9.99 This output is real: Playwright loaded the folder into Chromium 151 with --load-extension, a flag branded Chrome ignores since version 137. The [sw] lines appear in the service worker's console (linked from chrome://extensions). From Chrome 148 a listener may return a Promise, and every API is also exposed as browser.*.

    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>

    Listing 9.100: link-counter/popup.html

    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));

    Listing 9.101: link-counter/popup.js 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. Store Account Review notes Chrome Web Store One-time registration fee Days to weeks; no obfuscation Firefox Add-ons Mozilla account Signs all add-ons; source if minified Edge Add-ons Partner Center, no fee Chrome code, minimal changes Table 9.16: Where and how extensions are published Firefox needs two extra manifest keys under browser_specific_settings.gecko: an id such as "link-counter@example.com", required for Manifest V3 submissions, and, for every new extension since November 3, 2025, data_collection_permissions ({ "required": ["none"] } when you collect nothing). Chrome ignores both.

    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/

    Listing 9.102: Create, run and package a WXT extension for Chrome and Firefox