Hardware & Device APIs¶
Hardware and device APIs let a Progressive Web App talk to physical things: Bluetooth Low Energy peripherals, USB and HID devices, serial ports, NFC tags, motion sensors, game controllers, MIDI instruments, barcode-bearing camera frames and XR headsets. They replace the native helper apps, browser plugins and "download our driver" pages that used to sit between a web page and a device. Most of them ship only in Chromium-based browsers because Mozilla and Apple consider low-level device access too risky, so a production PWA treats each one as a progressive enhancement: detect it, ask for access through a chooser or prompt at the right moment, handle every rejection, and fall back gracefully everywhere else.
Key takeaways
- Web Bluetooth, WebUSB, WebHID and Web Serial all use the same chooser model: a call made during a user gesture opens a browser-owned picker, the user grants access to one device, and the page never sees devices it was not granted.
- Every device API requires a secure context, most are gated by a Permissions Policy token (
bluetooth,usb,hid,serial,midi,gamepad,geolocation, ...), and each has a blocklist or list of protected classes that keeps security keys, keyboards and similar devices out of reach. - Support is uneven. Geolocation, the device orientation and motion events and the Gamepad API work in every engine. WebUSB, WebHID, Web NFC, Generic Sensors and Barcode Detection are Chromium-only. Firefox 151 added Web Serial on desktop behind a site-permission add-on, and Web MIDI has shipped in Firefox since version 108 the same way.
- Mobile differs from desktop: WebHID has no Android support at all, Web NFC exists only on Android, and Web Serial reached Chrome for Android in stages (Bluetooth RFCOMM ports in Chrome 138, full support recorded from Chrome 148).
- Grants for USB, HID and serial devices persist and come back through
getDevices()/getPorts(). Web Bluetooth'sgetDevices()is still behind a flag, so Bluetooth PWAs must send the user through the chooser again after a reload. - Everything runs only while your page is open and (for most APIs) visible. No device API gives a PWA background access. Plan reconnection, not persistence.
Choosing the right device API¶
The first question is not "which API is cool" but "how does the device present itself to the operating system". If the OS already has a class driver for the device (keyboard, mouse, webcam, audio interface, mass storage), a higher-level web API usually exists and the low-level API will refuse to touch it. If the device speaks a vendor protocol over USB, a serial protocol, BLE GATT or NDEF, pick the API that matches the transport.
flowchart TD
A[Device to integrate] --> B{How is it connected?}
B -->|Bluetooth Low Energy GATT| BT[Web Bluetooth]
B -->|Bluetooth Classic SPP| SER[Web Serial - RFCOMM]
B -->|USB| C{What does the OS see?}
C -->|Virtual COM port / UART bridge| SER
C -->|HID class device| HID[WebHID]
C -->|Vendor-specific interface| USB[WebUSB]
C -->|Audio, video, storage, keyboard| HL[Use the high-level web API instead]
B -->|NFC tag, NDEF| NFC[Web NFC - Android only]
B -->|MIDI instrument| MIDI[Web MIDI]
B -->|Game controller| GP[Gamepad API]
B -->|Built-in sensors| S[Geolocation, DeviceOrientation, Generic Sensors] | You need to... | API | Entry point | Access model | Engines (Sept 2026) |
|---|---|---|---|---|
| Read a BLE heart-rate strap, thermometer or custom GATT peripheral | Web Bluetooth | navigator.bluetooth.requestDevice() | Chooser, per device | Chromium (not Linux by default) |
| Flash firmware, drive a vendor-protocol USB device | WebUSB | navigator.usb.requestDevice() | Chooser, per device | Chromium desktop and Android |
| Talk to a non-standard HID device (macro pad, LED controller, scale) | WebHID | navigator.hid.requestDevice() | Chooser, per device | Chromium desktop |
| Program a microcontroller, control a 3D printer or CNC machine | Web Serial | navigator.serial.requestPort() | Chooser, per port | Chromium desktop, Chrome for Android, Firefox 151+ desktop (add-on gated) |
| Read and write NDEF tags | Web NFC | new NDEFReader() | Permission prompt | Chrome for Android |
| Know where the user is | Geolocation | navigator.geolocation.*, <geolocation> | Permission prompt | All engines |
| React to tilt, rotation and shake | DeviceOrientation / DeviceMotion events, Generic Sensor API | window events, new Accelerometer() etc. | Permission on iOS and newer Chromium; auto-granted elsewhere | Events everywhere; sensor classes Chromium-only |
| Read controllers | Gamepad API | navigator.getGamepads() | Exposed after a gamepad button press | All engines |
| Scan QR codes and barcodes from a camera | Barcode Detection | new BarcodeDetector() | None (you pass the pixels) | Chrome on Android, macOS and ChromeOS |
| Connect a keyboard controller or synth | Web MIDI | navigator.requestMIDIAccess() | Permission prompt | Chromium, Firefox 108+ (add-on gated) |
| Render immersive VR/AR | WebXR | navigator.xr.requestSession() | User activation plus consent | Chromium, Quest Browser, Safari on visionOS |
Several neighboring capabilities have their own pages: files and folders are covered in File System Access, camera-to-share flows in Web Share, and audio, video, clipboard, wake lock and screen capture in Media & System APIs.
The security model all device APIs share¶
Device APIs are among the most dangerous capabilities the web has. A granted USB device can be reflashed. A serial-controlled machine can move physical parts. A HID device can be a keyboard in disguise. The specifications stack several independent defenses, and you need to understand each one because each produces a distinct failure in your code.
Secure context, top-level frames and Permissions Policy¶
Every API on this page is restricted to secure contexts. For the chooser APIs, Web NFC, Web MIDI, the sensor classes and WebXR the interfaces are simply missing on http: origins (other than localhost), so feature detection returns false. Geolocation and the orientation and motion events are older and behave differently: the interfaces stay visible, but position requests fail with PERMISSION_DENIED and the events never fire, so detect isSecureContext as well. Most APIs also declare a Permissions Policy feature whose default allowlist is 'self'. Same-origin iframes inherit access. Cross-origin iframes get nothing unless the embedding page delegates the feature with allow, and a Permissions-Policy response header can switch a feature off for your own origin as defense-in-depth.
| Policy token | API | Default allowlist | Failure when blocked |
|---|---|---|---|
bluetooth | Web Bluetooth | 'self' | SecurityError from requestDevice() (policy added in Chrome 104) |
usb | WebUSB | 'self' | navigator.usb calls reject with SecurityError |
hid | WebHID | 'self' | SecurityError |
serial | Web Serial | 'self' | SecurityError from requestPort() / getPorts() |
geolocation | Geolocation | 'self' | Error callback with PERMISSION_DENIED |
accelerometer, gyroscope, magnetometer | Generic Sensors, DeviceOrientation/Motion | 'self' | Sensor error event with SecurityError; orientation events never fire |
ambient-light-sensor | AmbientLightSensor | 'self' | SecurityError |
gamepad | Gamepad API | 'self' | getGamepads() throws SecurityError (policy added in Chrome 103) |
midi | Web MIDI | 'self' | requestMIDIAccess() rejects |
xr-spatial-tracking | WebXR | 'self' | requestSession() rejects |
Web NFC has no policy token at all: the specification only allows it in the active top-level browsing context, so an iframe can never scan or write tags.
<!-- Delegate serial and USB to a trusted cross-origin tool, nothing else -->
<iframe
src="https://flasher.example.com/"
allow="serial https://flasher.example.com; usb https://flasher.example.com">
</iframe>
Permissions-Policy: serial=(self "https://flasher.example.com"), usb=(self "https://flasher.example.com"), hid=(), bluetooth=()
See Permissions for the general permission lifecycle, how the Permissions API reports state and how users revoke grants.
Choosers are not permission prompts¶
Geolocation, Web NFC and Web MIDI use a classic permission prompt: "Allow example.com to use X?" The four low-level transports (Bluetooth, USB, HID, serial) use a chooser instead. The page describes the kind of device it wants with filters, the browser lists matching devices it can see, and the user picks exactly one. That design has consequences you will feel in your code:
- No enumeration before consent. A page cannot list connected USB devices, nearby BLE peripherals or serial ports to decide whether to show a "Connect" button. You can only call
getDevices()/getPorts(), which returns devices the user has already granted to your origin. - Transient user activation is mandatory for
requestDevice()/requestPort(). Calling them fromload, a timer or after anawaitthat outlived the activation window rejects withSecurityError. Put the call directly in aclickhandler, before any otherawait. - Cancelling is not an error in the usual sense. Web Bluetooth, WebUSB and Web Serial reject with
NotFoundErrorwhen the user closes the chooser. WebHID resolves with an empty array. Treat both as "user changed their mind", not as a failure to report. - Grants are per device and per origin. Two PWAs on different origins never share device access, and granting one USB device does not grant a second, identical one.
sequenceDiagram
participant U as User
participant P as PWA page
participant B as Browser
participant OS as OS / device stack
U->>P: click "Connect"
P->>B: requestDevice({ filters }) during transient activation
B->>OS: enumerate matching devices
B-->>U: chooser listing only matching devices
U->>B: selects one device
B->>B: store grant for origin + device
B-->>P: resolves with a device object
P->>OS: open(), claim, transfer (through the browser)
Note over P,B: On later visits: getDevices() returns granted devices without a chooser Blocklists and protected device classes¶
Filters decide what the user is offered. Blocklists decide what a page can use even when the user wants to allow it. Each API keeps its list in a public repository so security fixes can ship without spec changes.
| API | What is blocked | Where the list lives |
|---|---|---|
| Web Bluetooth | GATT services, characteristics and descriptors, either fully or for writes only (for example the HID-over-GATT service 0x1812 and the FIDO service 0xFFFD) | gatt_blocklist.txt in the WebBluetoothCG registries repository |
| WebUSB | Protected interface classes (audio, HID, mass storage, hub, smart card, video, audio/video, wireless controller) plus specific vendor/product/version entries | Class list in the WebUSB spec; device list in Chromium |
| WebHID | FIDO U2F usage page 0xF1D0, mouse, keyboard, keypad and system-control collections, specific vendor reports | blocklist.txt in the WICG/webhid repository |
| Web Serial | Bluetooth service class UUIDs other than the standard Serial Port Profile unless explicitly allowed; a custom-UUID blocklist | bluetooth-service-blocklist.txt in the WICG/serial repository |
The blocklists are why you cannot build a web-based FIDO key manager, keylogger, or mass-storage reader with these APIs. Protected functionality always has a dedicated, safer API: WebAuthn for security keys, getUserMedia() for cameras and microphones, File System Access for storage.
How long access lasts: getDevices(), forget() and revocation¶
| API | Grant persists across reloads? | Recover granted devices | Revoke from code |
|---|---|---|---|
| WebUSB | Yes, stored in site settings | navigator.usb.getDevices() | USBDevice.forget() (Chrome 101) |
| WebHID | Yes | navigator.hid.getDevices() | HIDDevice.forget() (Chrome 100) |
| Web Serial | Yes | navigator.serial.getPorts() | SerialPort.forget() (Chrome 103) |
| Web Bluetooth | The BluetoothDevice object is lost on reload | navigator.bluetooth.getDevices() exists only behind a flag in Chrome | BluetoothDevice.forget() also behind the flag |
| Geolocation, NFC, MIDI | Yes, as a normal site permission | navigator.permissions.query() | Not possible from code; the user resets it in site settings |
forget() matters more than it looks. When the user clicks "Disconnect" in your UI, calling forget() removes the grant so the device disappears from getDevices() and from the browser's site-settings page. That is the honest implementation of "disconnect", and it keeps your origin's list of granted devices from growing forever.
Where Firefox and Safari stand¶
The standards positions explain the support tables better than any list of version numbers. Mozilla and Apple both argue that users cannot meaningfully judge the risk of handing raw device access to a website, and that device firmware is too often unprepared for hostile input.
| API | Mozilla position | WebKit position |
|---|---|---|
| Web Bluetooth | Negative | Oppose |
| WebUSB | Negative | Oppose |
| WebHID | Negative | No formal position; listed by WebKit among APIs it has decided not to implement |
| Web Serial | Neutral, and shipped in Firefox 151 on desktop behind a site-permission add-on | Oppose |
| Web NFC | Negative | Oppose |
| Generic Sensor API (Accelerometer, Gyroscope, Magnetometer, orientation sensors) | Negative | Oppose |
| Shape Detection (BarcodeDetector) | Defer | Support (implemented behind a flag since Safari 17) |
| Web MIDI | Positive (shipped, add-on gated) | Listed among APIs WebKit has decided not to implement |
| WebXR Device API | Positive | Shipped in Safari on visionOS |
<geolocation> element | Positive | No position yet |
Mozilla's compromise for "powerful but useful" device APIs is the site permission add-on: the first call triggers a prompt to install a small, synthetically generated add-on that grants the permission to that one site. Firefox uses it for Web MIDI (since Firefox 108) and for Web Serial (Firefox 151, released May 19, 2026). From your code's point of view it is just a slower permission flow that can still end in a rejection.
Apple's list of APIs it has "decided to not yet implement due to fingerprinting, security, and other concerns" includes Web Bluetooth, Web MIDI, Magnetometer, Web NFC, Ambient Light Sensor, WebHID, Serial API, Web USB and User Idle Detection. Every iOS and iPadOS browser uses WebKit, so on an iPhone none of these APIs exist in any browser, installed PWA or not. See iOS & iPadOS for the full picture.
Feature detection that does not lie¶
Presence of the interface is necessary but not sufficient. Firefox exposes navigator.serial but the first requestPort() may involve an add-on install. Chrome on Windows may expose BarcodeDetector without supporting any formats on that platform. Linux Chrome may expose navigator.bluetooth while getAvailability() reports no usable adapter. Build a capability module that checks the deeper signal where one exists.
// Detect device APIs once, at startup, and expose a frozen capability map.
// Deeper probes (adapter availability, supported barcode formats) are async.
export async function detectDeviceCapabilities() {
const caps = {
secureContext: globalThis.isSecureContext === true,
bluetooth: "bluetooth" in navigator,
bluetoothAdapter: false,
usb: "usb" in navigator,
hid: "hid" in navigator,
serial: "serial" in navigator,
nfc: "NDEFReader" in globalThis,
geolocation: "geolocation" in navigator,
geolocationElement: "HTMLGeolocationElement" in globalThis,
orientationEvents: "DeviceOrientationEvent" in globalThis,
orientationNeedsPermission:
typeof globalThis.DeviceOrientationEvent?.requestPermission === "function",
genericSensors: "Accelerometer" in globalThis && "Gyroscope" in globalThis,
gamepad: typeof navigator.getGamepads === "function",
midi: typeof navigator.requestMIDIAccess === "function",
xr: "xr" in navigator,
barcodeFormats: [],
};
if (caps.bluetooth) {
try {
// Resolves false when there is no adapter or the platform blocks Bluetooth.
caps.bluetoothAdapter = await navigator.bluetooth.getAvailability();
} catch {
caps.bluetoothAdapter = false;
}
}
if ("BarcodeDetector" in globalThis) {
try {
// An empty list means "interface present, nothing usable on this OS".
caps.barcodeFormats = await BarcodeDetector.getSupportedFormats();
} catch {
caps.barcodeFormats = [];
}
}
return Object.freeze(caps);
}
Use the result to show or hide features, not to block the app. A PWA that flashes firmware can still show documentation, logs and a download link for a native flasher when serial is false.
Installed PWAs versus browser tabs¶
Installing a PWA does not change the device permission model: the same chooser appears in the standalone window, grants are keyed by origin rather than by install state, and a grant made in a tab is visible to the installed app on the same origin. Two things do change in practice:
- An installed app window is more likely to stay open for long sessions, so reconnection logic (
connect/disconnectevents, backoff) matters more than in a tab. - Isolated Web Apps can declare the
usb-unrestrictedPermissions Policy feature in their manifest. The WebUSB specification defines it as the only way to reach blocklisted devices and protected interface classes, and it is not available to ordinary PWAs.
Web Bluetooth¶
Web Bluetooth lets a page act as a Bluetooth Low Energy central that connects to a peripheral and uses its GATT (Generic Attribute Profile) server: services, characteristics and descriptors. It covers the large class of BLE devices built on standard or custom GATT profiles: heart-rate straps, cycling power meters, thermometers, glucose meters, smart bulbs, toys, lab equipment and microcontroller boards.
What it does not cover matters just as much:
- No Bluetooth Classic (BR/EDR) profiles. Classic serial (SPP/RFCOMM) is reachable through Web Serial instead.
- No peripheral or advertiser role: the page cannot advertise or act as a GATT server.
- No raw L2CAP channels.
- Scanning for advertisements without connecting (
requestLEScan(),BluetoothDevice.watchAdvertisements()) remains experimental and behind flags.
Requesting a device: filters and options¶
navigator.bluetooth.requestDevice(options) takes a RequestDeviceOptions dictionary. The spec enforces that you pass either filters or acceptAllDevices: true, never both and never neither.
| Member | Type / default | Meaning |
|---|---|---|
filters | BluetoothLEScanFilterInit[] | Devices matching any filter are listed. Each filter can require services, an exact name, a namePrefix, manufacturerData or serviceData (all conditions in one filter must match). |
exclusionFilters | BluetoothLEScanFilterInit[] | Devices matching any exclusion filter are removed from the chooser (Chrome 114). Requires filters. |
optionalServices | BluetoothServiceUUID[], default [] | Extra services you want to access after connecting. Services not listed in a filter or here are inaccessible later. |
optionalManufacturerData | unsigned short[], default [] | Company identifiers whose manufacturer data you want to read from advertisements. |
acceptAllDevices | boolean, default false | List every nearby device. You still need optionalServices to use any service. |
Manufacturer-data and service-data filters (Chrome 92) match advertisement bytes with a dataPrefix and an optional mask, so you can target a product line that advertises a model byte:
const device = await navigator.bluetooth.requestDevice({
filters: [
{
// Company identifier assigned by the Bluetooth SIG (example value).
manufacturerData: [
{
companyIdentifier: 0x00e0,
dataPrefix: new Uint8Array([0x01, 0x02]),
mask: new Uint8Array([0xff, 0xf0]), // compare the high nibble of byte 2 only
},
],
},
],
exclusionFilters: [{ namePrefix: "Bootloader" }], // hide devices in DFU mode
optionalServices: ["battery_service"],
});
Service, characteristic and descriptor identifiers are BluetoothServiceUUID values: a full 128-bit UUID string, a 16- or 32-bit alias number (0x180d), or a name from the GATT assigned numbers registry ("heart_rate"). BluetoothUUID.getService("heart_rate") returns the canonical "0000180d-0000-1000-8000-00805f9b34fb", and BluetoothUUID.canonicalUUID(0x2a37) expands an alias. Custom services on your own hardware always use full 128-bit UUIDs.
The error surface of requestDevice():
| Rejection | Cause |
|---|---|
TypeError | Neither or both of filters / acceptAllDevices; empty filters or exclusionFilters array; exclusionFilters without filters; an unknown service name |
SecurityError | No transient activation, Permissions Policy blocks bluetooth, or a filter names a blocklisted service |
NotFoundError | The user cancelled the chooser, or no device could ever match |
NotSupportedError / NotFoundError from the adapter | No Bluetooth adapter, or Bluetooth is off at the OS level |
Connecting, reading and subscribing to notifications¶
The following module connects to any device exposing the standard Heart Rate service, reads the Body Sensor Location characteristic, subscribes to Heart Rate Measurement notifications and parses them according to the GATT specification. It is complete and uses only standard assigned numbers, so it works with most chest straps and many watches in broadcast mode.
// Connects to a BLE heart-rate sensor and emits parsed measurements.
// Usage: const hr = new HeartRateMonitor(); hr.addEventListener("measurement", ...);
// button.onclick = () => hr.connect(); // must run inside the click handler
const SENSOR_LOCATIONS = ["Other", "Chest", "Wrist", "Finger", "Hand", "Ear lobe", "Foot"];
export class HeartRateMonitor extends EventTarget {
#device = null;
#characteristic = null;
#reconnectAttempts = 0;
#userDisconnected = false;
get connected() {
return Boolean(this.#device?.gatt?.connected);
}
async connect() {
// requestDevice() MUST be the first awaited call in the click handler,
// otherwise transient activation may have expired.
this.#device = await navigator.bluetooth.requestDevice({
filters: [{ services: ["heart_rate"] }],
optionalServices: ["battery_service"],
});
this.#userDisconnected = false;
this.#device.addEventListener("gattserverdisconnected", () => this.#onDisconnected());
await this.#connectGatt();
return this.#device.name ?? "Unnamed sensor";
}
async #connectGatt() {
const server = await this.#device.gatt.connect();
const service = await server.getPrimaryService("heart_rate");
// Body Sensor Location is optional in the profile: tolerate its absence.
try {
const location = await service.getCharacteristic("body_sensor_location");
const value = await location.readValue();
this.dispatchEvent(
new CustomEvent("location", { detail: SENSOR_LOCATIONS[value.getUint8(0)] ?? "Unknown" }),
);
} catch (error) {
if (error.name !== "NotFoundError") throw error;
}
this.#characteristic = await service.getCharacteristic("heart_rate_measurement");
this.#characteristic.addEventListener("characteristicvaluechanged", (event) => {
// event.target.value is a DataView over the notification payload.
this.dispatchEvent(
new CustomEvent("measurement", { detail: parseHeartRate(event.target.value) }),
);
});
await this.#characteristic.startNotifications();
this.#reconnectAttempts = 0;
this.dispatchEvent(new Event("connected"));
}
async #onDisconnected() {
this.dispatchEvent(new Event("disconnected"));
if (this.#userDisconnected) return;
// Exponential backoff: 1s, 2s, 4s ... capped at 30s, max 8 attempts.
while (!this.#userDisconnected && this.#reconnectAttempts < 8) {
const delay = Math.min(30_000, 1000 * 2 ** this.#reconnectAttempts++);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
// gatt.connect() does not need a user gesture once the device was granted.
await this.#connectGatt();
return;
} catch (error) {
console.warn(`Reconnect attempt ${this.#reconnectAttempts} failed`, error);
}
}
this.dispatchEvent(new Event("gaveup"));
}
async disconnect() {
this.#userDisconnected = true;
try {
await this.#characteristic?.stopNotifications();
} catch {
// The link may already be gone; stopping notifications is best-effort.
}
this.#device?.gatt?.disconnect();
}
}
// Heart Rate Measurement (0x2A37) layout:
// byte 0 flags: bit0 = 16-bit value, bit1 = contact detected, bit2 = contact supported,
// bit3 = energy expended present, bit4 = RR intervals present
export function parseHeartRate(view) {
const flags = view.getUint8(0);
let offset = 1;
const result = {};
if (flags & 0x01) {
result.heartRate = view.getUint16(offset, /* littleEndian */ true);
offset += 2;
} else {
result.heartRate = view.getUint8(offset);
offset += 1;
}
if (flags & 0x04) result.contactDetected = Boolean(flags & 0x02);
if (flags & 0x08) {
result.energyExpendedKJ = view.getUint16(offset, true);
offset += 2;
}
if (flags & 0x10) {
result.rrIntervalsSec = [];
for (; offset + 1 < view.byteLength; offset += 2) {
result.rrIntervalsSec.push(view.getUint16(offset, true) / 1024); // 1/1024 s units
}
}
return result;
}
Every GATT value arrives as a DataView. Always pass the endianness flag explicitly: GATT is little-endian, DataView defaults to big-endian, and forgetting the second argument is the most common source of "the numbers are garbage" bug reports.
Writing values and the one-operation-at-a-time rule¶
BluetoothRemoteGATTCharacteristic offers three write methods:
| Method | Link-layer behavior | Use it for |
|---|---|---|
writeValueWithResponse(value) | GATT Write Request; resolves after the peripheral acknowledges | Commands and configuration where you must know it arrived |
writeValueWithoutResponse(value) | GATT Write Command; resolves once queued | High-rate streaming (LED frames, motor setpoints) |
writeValue(value) | Picks one based on the characteristic's properties | Legacy code only; deprecation is proposed on chromestatus |
The explicit variants shipped in Chrome 85. Check characteristic.properties.write and .writeWithoutResponse before choosing. A value larger than the negotiated ATT MTU minus 3 bytes fails or is truncated depending on the platform, so chunk large payloads at the protocol level your firmware expects.
Chromium's Bluetooth stack processes one GATT operation per device at a time on several platforms. Two overlapping readValue() or writeValue*() calls fail with NetworkError: GATT operation already in progress. The specification acknowledges this and tells sites to serialize their calls. A tiny promise queue solves it for the whole app:
// Serializes GATT operations: each task starts only after the previous one settles.
export class GattQueue {
#tail = Promise.resolve();
run(task) {
const result = this.#tail.then(task, task); // run even if the previous task failed
this.#tail = result.catch(() => {}); // keep the chain alive
return result;
}
}
// Usage
const queue = new GattQueue();
const encoder = new TextEncoder();
await Promise.all([
queue.run(() => ledCharacteristic.writeValueWithResponse(Uint8Array.of(0xff, 0x00, 0x00))),
queue.run(() => nameCharacteristic.writeValueWithResponse(encoder.encode("Kitchen"))),
queue.run(() => batteryLevel.readValue()),
]);
Handling disconnects¶
BLE links drop constantly: the user walks away, the peripheral sleeps to save power, the OS reclaims the radio. gattserverdisconnected fires on the BluetoothDevice. Your BluetoothDevice object stays valid while the page lives, so reconnecting only needs device.gatt.connect() without a new gesture, which is what the backoff loop above does. Notifications do not resume on their own after reconnecting: call startNotifications() again and re-fetch services and characteristics, because the objects from the previous connection are stale.
After a page reload the BluetoothDevice is gone. Because navigator.bluetooth.getDevices() still requires chrome://flags/#enable-web-bluetooth-new-permissions-backend (chromestatus lists it as a developer trial), a PWA has to show a "Reconnect" button that repeats requestDevice(). Design the UI so that this is one tap, and remember which device the user picked (by device.name or an app-level identifier) so you can pre-fill filters that narrow the chooser to it.
device.id is an opaque, origin-scoped identifier generated by the browser, not the device's Bluetooth address. It is stable for the same origin and device in Chrome but is not guaranteed to be stable across browsers or profiles, and it is useless for identifying the device anywhere else.
The GATT blocklist¶
Some attributes are too dangerous to expose even to a site the user trusts. The GATT blocklist marks UUIDs as fully blocked or blocked for writes (exclude-writes). Selected entries:
| UUID | Name | Blocked for |
|---|---|---|
0x1812 | Human Interface Device service | Everything (keystroke injection) |
0xFFFD | FIDO U2F service | Everything (security keys belong to WebAuthn) |
0x2A03 | Reconnection Address | Everything |
0x2A25 | Serial Number String | Everything (fingerprinting) |
0x2A02 | Peripheral Privacy Flag | Writes |
0x2902 | Client Characteristic Configuration descriptor | Writes (use startNotifications() instead) |
0x2903 | Server Characteristic Configuration descriptor | Writes |
Accessing a blocked UUID rejects with SecurityError. Filtering on a blocked service in requestDevice() also throws.
Platform notes¶
- Android: Chrome for Android since version 56. Android also requires Location to be enabled for BLE scanning on older Android versions, and Chrome prompts for the "Nearby devices" or location runtime permission the first time.
- Windows: Chrome 70 and later, on Windows 10 version 1703 (Creators Update) or newer.
- macOS and ChromeOS: supported. macOS asks the user to allow the browser to use Bluetooth the first time.
- Linux: MDN's compatibility data notes that Linux support is not enabled by default; the Web Bluetooth community group's status page says it requires
chrome://flags/#enable-experimental-web-platform-features. - iOS and iPadOS: no support in any browser, because all of them use WebKit.
- Android WebView: not supported, which also affects apps that wrap web content in a WebView rather than a Trusted Web Activity.
Check navigator.bluetooth.getAvailability() and listen for the availabilitychanged event to grey out the "Connect" button when the adapter is off.
WebUSB¶
WebUSB gives a page direct access to USB devices that no operating-system class driver has claimed. It is how browser-based firmware flashers, programmable keyboards with vendor configuration interfaces, lab instruments, receipt printers with vendor protocols and hardware wallets' vendor interfaces work without installing native software.
The USB model in five objects¶
WebUSB mirrors the USB descriptor hierarchy one-to-one:
| USB concept | WebUSB object | What you do with it |
|---|---|---|
| Device | USBDevice | open(), close(), reset(), forget(); read vendorId, productId, serialNumber, productName |
| Configuration | USBConfiguration (device.configurations, device.configuration) | selectConfiguration(value) if the device is unconfigured |
| Interface | USBInterface | claimInterface(number) / releaseInterface(number) |
| Alternate setting | USBAlternateInterface (interfaceClass, interfaceSubclass, interfaceProtocol) | selectAlternateInterface(number, alt) |
| Endpoint | USBEndpoint (endpointNumber, direction, type, packetSize) | transferIn(), transferOut(), isochronous transfers |
Transfers map to the four USB transfer types:
| Transfer type | Methods | Result object |
|---|---|---|
| Control (endpoint 0) | controlTransferIn(setup, length), controlTransferOut(setup, data?) | USBInTransferResult / USBOutTransferResult |
| Bulk and interrupt | transferIn(endpointNumber, length), transferOut(endpointNumber, data) | Same, with status of "ok", "stall" or "babble" |
| Isochronous | isochronousTransferIn(endpoint, packetLengths), isochronousTransferOut(endpoint, data, packetLengths) | Per-packet results |
A control transfer's setup object has requestType ("standard", "class" or "vendor"), recipient ("device", "interface", "endpoint" or "other"), request, value and index. A "stall" status is a protocol-level refusal from the device, not an exception; clear it with device.clearHalt(direction, endpointNumber) before retrying on that endpoint.
Requesting a device¶
navigator.usb.requestDevice({ filters, exclusionFilters }): filters is required (pass [] to list everything, which you should avoid in production). A device matches a USBDeviceFilter when every present member matches:
| Filter member | Matches |
|---|---|
vendorId | Device vendor ID |
productId | Product ID (only meaningful with vendorId) |
classCode, subclassCode, protocolCode | Device class or any interface's class, subclass, protocol |
serialNumber | Exact serial number string |
exclusionFilters (Chrome 117) removes devices from the chooser, for example a bootloader product ID you handle elsewhere.
A complete vendor-interface driver¶
This class drives a device that exposes a vendor-specific interface (class 0xFF) with one bulk IN and one bulk OUT endpoint, which is the pattern used by most microcontroller WebUSB firmware. It claims the right interface by inspecting descriptors instead of hard-coding numbers, reads in a loop, recovers from stalls and cleans up on disconnect.
// Bulk-endpoint link to a vendor-specific USB interface.
const VENDOR_CLASS = 0xff;
export class UsbLink extends EventTarget {
#device;
#interfaceNumber;
#endpointIn;
#endpointOut;
#reading = false;
constructor(device) {
super();
this.#device = device;
navigator.usb.addEventListener("disconnect", (event) => {
if (event.device === this.#device) {
this.#reading = false;
this.dispatchEvent(new Event("disconnected"));
}
});
}
// Call from a click handler. Filters are examples: use your own vendor/product IDs.
static async request(filters = [{ vendorId: 0x2341 }]) {
const device = await navigator.usb.requestDevice({ filters });
return new UsbLink(device);
}
// Reconnect to previously granted devices without a chooser.
static async restore(vendorId) {
const devices = await navigator.usb.getDevices();
const device = devices.find((d) => d.vendorId === vendorId);
return device ? new UsbLink(device) : null;
}
async open() {
const device = this.#device;
if (!device.opened) await device.open();
// Some devices arrive unconfigured; configuration values start at 1.
if (device.configuration === null) await device.selectConfiguration(1);
const { iface, alternate } = findVendorInterface(device.configuration);
this.#interfaceNumber = iface.interfaceNumber;
await device.claimInterface(this.#interfaceNumber); // fails if an OS driver owns it
if (alternate.alternateSetting !== 0) {
await device.selectAlternateInterface(this.#interfaceNumber, alternate.alternateSetting);
}
for (const endpoint of alternate.endpoints) {
if (endpoint.type !== "bulk") continue;
if (endpoint.direction === "in") this.#endpointIn = endpoint;
else this.#endpointOut = endpoint;
}
if (!this.#endpointIn || !this.#endpointOut) {
throw new Error("Vendor interface lacks bulk IN/OUT endpoints");
}
// Many CDC-like firmwares wait for a "host connected" signal. This mirrors
// SET_CONTROL_LINE_STATE (0x22) with DTR set; adapt to your firmware's protocol.
await device.controlTransferOut({
requestType: "class",
recipient: "interface",
request: 0x22,
value: 0x01,
index: this.#interfaceNumber,
});
this.#readLoop();
}
async #readLoop() {
this.#reading = true;
const { endpointNumber, packetSize } = this.#endpointIn;
while (this.#reading) {
try {
// Request a multiple of the max packet size to avoid "babble" overflows.
const result = await this.#device.transferIn(endpointNumber, packetSize * 8);
if (result.status === "stall") {
await this.#device.clearHalt("in", endpointNumber);
continue;
}
if (result.data?.byteLength) {
this.dispatchEvent(new CustomEvent("data", { detail: new Uint8Array(result.data.buffer) }));
}
} catch (error) {
// NetworkError here usually means the device was unplugged.
this.#reading = false;
if (error.name !== "NetworkError" && error.name !== "AbortError") {
this.dispatchEvent(new CustomEvent("error", { detail: error }));
}
}
}
}
async write(bytes) {
const result = await this.#device.transferOut(this.#endpointOut.endpointNumber, bytes);
if (result.status === "stall") {
await this.#device.clearHalt("out", this.#endpointOut.endpointNumber);
throw new Error("Device stalled the OUT endpoint");
}
return result.bytesWritten;
}
async close({ forget = false } = {}) {
this.#reading = false;
try {
await this.#device.controlTransferOut({
requestType: "class", recipient: "interface", request: 0x22, value: 0x00,
index: this.#interfaceNumber,
});
await this.#device.releaseInterface(this.#interfaceNumber);
await this.#device.close();
} finally {
if (forget) await this.#device.forget(); // revoke the grant entirely
}
}
}
function findVendorInterface(configuration) {
for (const iface of configuration.interfaces) {
for (const alternate of iface.alternates) {
if (alternate.interfaceClass === VENDOR_CLASS) return { iface, alternate };
}
}
throw new Error("No vendor-specific (0xFF) interface found");
}
Note the pending transferIn() that sits in the read loop: close() aborts it, which is why the loop swallows AbortError. Never call transferIn() twice concurrently on the same endpoint unless your protocol expects multiple outstanding reads.
Protected interface classes¶
The WebUSB specification defines interface classes that pages may not claim, because a higher-level, better-protected API or OS driver owns them:
| Class code | Class | Use instead |
|---|---|---|
0x01 | Audio | Web Audio, getUserMedia() |
0x03 | HID | WebHID (with its own blocklist) |
0x08 | Mass Storage | File System Access |
0x09 | Hub | Not exposed |
0x0B | Smart Card | Not exposed to ordinary pages |
0x0E | Video | getUserMedia() |
0x10 | Audio/Video Devices | getUserMedia() |
0xE0 | Wireless Controller (Bluetooth adapters and similar) | Web Bluetooth |
A composite device can still be opened: you may claim its vendor interface while its HID interface stays with the OS. claimInterface() on a protected interface rejects with SecurityError. On top of the class rules, Chromium keeps a device-level blocklist for specific products whose firmware can be abused (for example some security keys).
Operating-system drivers: the most common deployment problem¶
When open() or claimInterface() fails on a customer machine but works on yours, the cause is almost always the OS, not your code:
- Windows: the browser can only reach an interface that is bound to the generic WinUSB driver. Ship Microsoft OS 2.0 descriptors in your firmware so Windows binds WinUSB automatically; otherwise users need a driver-install tool.
- Linux: the device node must be accessible to the user. Ship a
udevrule granting access for your vendor/product IDs, oropen()rejects withSecurityError: Access denied. - macOS: if a kernel driver (for example the CDC-ACM serial driver) has claimed an interface,
claimInterface()rejects withNetworkError: Unable to claim interface. Use Web Serial for interfaces the OS turns into serial ports. - Android: Chrome for Android supports WebUSB since version 61 for devices that Android itself does not claim. Android asks for USB access for the browser app on first use.
Workers and debugging¶
navigator.usb is exposed in dedicated workers (MDN records this since Chrome 70), so you can move a chatty transfer loop off the main thread. requestDevice() is window-only: obtain the grant in the page, then call getDevices() in the worker. Normal service workers do not get WebUSB; only extension service workers do (Chrome 118).
Chrome's about://usb-internals page lists devices with their descriptors and lets you simulate test devices. about://device-log shows low-level errors (driver claims, permission failures) that the JavaScript exception message omits.
WebHID¶
The Human Interface Device class covers far more than keyboards and mice: game controllers with non-standard features, stream decks and macro pads, LED controllers, barcode scanners in HID mode, scales, foot pedals, VR accessories and telephony headsets all use HID reports. WebHID gives a page access to those devices' reports without a native driver, while keeping keyboards, mice and security keys off limits.
Reports, collections and usages¶
A HID device describes itself with a report descriptor. What matters to your code:
- The descriptor defines top-level collections, each identified by a usage page and a usage (for example Generic Desktop
0x01/ Joystick0x04, or vendor-defined page0xFF00). WebHID exposes them asdevice.collections. - Data moves in reports: input reports (device to host, delivered as
inputreportevents), output reports (sendReport()) and feature reports (sendFeatureReport()/receiveFeatureReport()), each optionally prefixed by a report ID. When a device does not use report IDs, pass0. - One physical device can expose several HID interfaces. The chooser shows it once, and granting it grants all of its HID interfaces, so
requestDevice()resolves with an array ofHIDDeviceobjects.
Requesting a device¶
navigator.hid.requestDevice({ filters, exclusionFilters }) requires filters. Each HIDDeviceFilter can contain vendorId, productId (needs vendorId), usagePage and usage (needs usagePage). A filter must not be empty. A device matches when its IDs match and at least one of its collections matches the usage rules. exclusionFilters, if present, must be non-empty.
Unlike the other chooser APIs, cancelling the chooser resolves with [] rather than rejecting. Check the length.
A complete HID device controller¶
// Generic controller for a vendor-defined HID device.
// Report IDs and payload layouts below are illustrative; take real values from
// your device's report descriptor (about://device-log or a USB descriptor dump).
const FILTERS = [{ vendorId: 0x1234, usagePage: 0xff00 }]; // example values
const REPORT_ID_STATUS = 0x01; // input report: status updates
const REPORT_ID_COMMAND = 0x02; // output report: commands
const FEATURE_ID_CONFIG = 0x03; // feature report: persistent configuration
export async function connectHidDevice() {
// Must run inside a user gesture.
const devices = await navigator.hid.requestDevice({ filters: FILTERS });
if (devices.length === 0) return null; // user cancelled: not an error
return openHid(pickVendorInterface(devices));
}
export async function restoreHidDevice() {
const devices = await navigator.hid.getDevices();
const candidates = devices.filter((d) => d.vendorId === FILTERS[0].vendorId);
return candidates.length ? openHid(pickVendorInterface(candidates)) : null;
}
function pickVendorInterface(devices) {
// Choose the interface whose collections include the vendor usage page.
return (
devices.find((d) => d.collections.some((c) => c.usagePage === 0xff00)) ?? devices[0]
);
}
async function openHid(device) {
if (!device.opened) await device.open();
device.addEventListener("inputreport", (event) => {
const { reportId, data } = event; // data is a DataView WITHOUT the report ID byte
if (reportId !== REPORT_ID_STATUS) return;
const status = {
battery: data.getUint8(0),
buttons: data.getUint16(1, true),
temperatureC: data.getInt16(3, true) / 100,
};
device.dispatchEvent(new CustomEvent("status", { detail: status }));
});
return {
device,
async setLed(r, g, b) {
await device.sendReport(REPORT_ID_COMMAND, Uint8Array.of(0x10, r, g, b));
},
async readConfig() {
// The returned DataView INCLUDES the report ID as its first byte.
const view = await device.receiveFeatureReport(FEATURE_ID_CONFIG);
return { brightness: view.getUint8(1), mode: view.getUint8(2) };
},
async writeConfig({ brightness, mode }) {
await device.sendFeatureReport(FEATURE_ID_CONFIG, Uint8Array.of(brightness, mode));
},
async close({ forget = false } = {}) {
await device.close();
if (forget) await device.forget();
},
};
}
navigator.hid?.addEventListener("disconnect", ({ device }) => {
console.info(`HID device removed: ${device.productName}`);
});
The asymmetry in the comments is real and bites everyone once: inputreport events give you reportId separately and data without it, while receiveFeatureReport() returns a DataView that starts with the report ID byte. sendReport() and sendFeatureReport() take the ID as a separate argument and the payload without it.
Report sizes are fixed by the descriptor. What happens with a wrong-sized payload depends on the operating system's HID stack: Chromium zero-pads short output reports on some platforms, while a write the OS refuses rejects with NotAllowedError ("Failed to write the report"). Build every report at exactly the declared size so the behavior is the same everywhere.
What WebHID blocks¶
The WebHID blocklist is short and targeted:
| Rule | Reason |
|---|---|
Usage page 0xF1D0 | FIDO U2F: security keys are only reachable through WebAuthn |
Generic Desktop 0x01 / Mouse 0x02 | Input logging and focus-model subversion |
Generic Desktop 0x01 / Keyboard 0x06 and Keypad 0x07 | Keylogging |
Generic Desktop 0x01 / System Control 0x80 | System-wide power and sleep controls |
| Specific vendor entries (for example certain headset output reports) | Vendor-reported firmware risks |
Reports inside blocked collections cannot be sent or received. A gaming keyboard with an extra vendor-defined collection for RGB lighting is still usable: you get the vendor collection, never the keystrokes.
WebHID support¶
WebHID ships in Chromium-based browsers on Windows, macOS, Linux and ChromeOS since Chrome 89. There is no support in Chrome for Android. Dedicated workers gained navigator.hid in Chrome 131 (extension service workers earlier, in Chrome 117). Mozilla's position is negative, and WebKit lists WebHID among APIs it has decided not to implement.
Web Serial¶
Web Serial reads and writes serial ports with WHATWG Streams. It is the workhorse of web-based maker and industrial tooling: microcontroller IDEs and flashers, 3D-printer and CNC control panels, GPS and modem configuration, oscilloscopes and data loggers.
What counts as a serial port¶
- USB CDC-ACM devices (most microcontroller boards with native USB).
- USB-to-UART bridges, which the OS turns into COM ports or
/dev/tty*devices. - Built-in platform UARTs.
- Bluetooth Classic devices offering an RFCOMM service such as the Serial Port Profile (desktop Chrome 117 and later).
Requesting a port¶
navigator.serial.requestPort(options) accepts:
| Option | Meaning |
|---|---|
filters | Array of SerialPortFilter: { usbVendorId, usbProductId } for USB ports, or { bluetoothServiceClassId } for Bluetooth ports. A filter cannot mix the two, cannot be empty, and usbProductId requires usbVendorId (violations reject with TypeError). |
allowedBluetoothServiceClassIds | Custom RFCOMM service class UUIDs to include. Without it, only the standard Serial Port Profile (00001101-0000-1000-8000-00805f9b34fb) appears. |
The method needs transient activation and the serial policy. If the user closes the chooser it rejects with NotFoundError. port.getInfo() tells you what was picked: { usbVendorId, usbProductId } or { bluetoothServiceClassId }.
Opening a port: every option¶
SerialOptions member | Type / default | Valid values |
|---|---|---|
baudRate | unsigned long, required | Any positive value your hardware supports (9600, 115200, 921600 ...). 0 throws TypeError. |
dataBits | default 8 | 7 or 8 |
stopBits | default 1 | 1 or 2 |
parity | default "none" | "none", "even", "odd" |
bufferSize | default 255 | Size in bytes of the read and write buffers (becomes the streams' high-water mark). 0 throws TypeError; values the platform cannot allocate also throw. |
flowControl | default "none" | "none" or "hardware" (RTS/CTS) |
open() rejects with InvalidStateError if the port is already open in this page and with NetworkError if the OS fails to open it, typically because another program (a serial monitor, a slicer, another tab) holds it.
bufferSize of 255 is small for high-baud data loggers. At 921600 baud a device can deliver around 90 KB per second, and if your read loop stalls for a moment the OS buffer overruns and the readable stream errors with BufferOverrunError. Raise bufferSize (for example to 64 KB) for bursty devices.
Reading, writing and closing correctly¶
port.readable is a byte ReadableStream of Uint8Array chunks and port.writable a WritableStream. Chunk boundaries are arbitrary: a 40-byte message can arrive as 1 + 39 bytes. Framing is your job. The class below handles framing by line, backpressure, recoverable errors and the close sequence the specification requires (cancel the reader, abort or close the writer, then close the port).
// Line-oriented serial connection with correct error recovery and shutdown.
class LineBreakTransformer {
#buffer = "";
transform(chunk, controller) {
this.#buffer += chunk;
const lines = this.#buffer.split(/\r?\n/);
this.#buffer = lines.pop(); // keep the incomplete tail
for (const line of lines) controller.enqueue(line);
}
flush(controller) {
if (this.#buffer) controller.enqueue(this.#buffer);
}
}
const RECOVERABLE = new Set(["BufferOverrunError", "BreakError", "FramingError", "ParityError"]);
export class SerialConnection extends EventTarget {
#port;
#reader = null;
#keepReading = false;
#readLoopDone = Promise.resolve();
#writeQueue = Promise.resolve();
constructor(port) {
super();
this.#port = port;
port.addEventListener("disconnect", () => this.dispatchEvent(new Event("disconnected")));
}
static async request(filters) {
const port = await navigator.serial.requestPort(filters ? { filters } : {});
return new SerialConnection(port);
}
async open(options = { baudRate: 115200, bufferSize: 64 * 1024 }) {
await this.#port.open(options);
this.#keepReading = true;
this.#readLoopDone = this.#readLoop();
}
async #readLoop() {
// Outer loop: a recoverable error (parity, framing, overrun, break) errors the
// current readable; port.readable returns a NEW stream afterwards.
while (this.#port.readable && this.#keepReading) {
const decoder = new TextDecoderStream();
// Keep the pipe promise: port.readable stays locked until it settles.
const pipeDone = this.#port.readable.pipeTo(decoder.writable).catch(() => {});
this.#reader = decoder.readable
.pipeThrough(new TransformStream(new LineBreakTransformer()))
.getReader();
try {
for (;;) {
const { value, done } = await this.#reader.read();
if (done) break; // reader.cancel() was called
this.dispatchEvent(new CustomEvent("line", { detail: value }));
}
} catch (error) {
if (!RECOVERABLE.has(error.name)) {
// NetworkError = device unplugged: port.readable becomes null, loop exits.
this.dispatchEvent(new CustomEvent("error", { detail: error }));
}
} finally {
this.#reader.releaseLock();
this.#reader = null;
await pipeDone; // port.readable is unlocked only after this
}
}
}
// Writes are serialized: port.writable allows one writer at a time, and a
// second getWriter() while the first is held throws TypeError.
writeLine(text) {
const task = async () => {
const writer = this.#port.writable.getWriter();
try {
// Awaiting write() applies backpressure from the transmit buffer.
await writer.write(new TextEncoder().encode(`${text}\r\n`));
} finally {
writer.releaseLock();
}
};
const result = this.#writeQueue.then(task, task);
this.#writeQueue = result.catch(() => {}); // keep the chain alive after a failure
return result;
}
async close() {
this.#keepReading = false;
// Cancelling propagates back through both pipes and cancels port.readable.
await this.#reader?.cancel().catch(() => {});
await this.#readLoopDone; // waits for pipeDone, i.e. for port.readable to unlock
await this.#writeQueue; // no writer may hold port.writable
// port.close() rejects if either stream is still locked.
await this.#port.close();
}
async forget() {
if (this.#port.readable || this.#port.writable) await this.close();
await this.#port.forget();
}
}
A pipe keeps its source locked until the pipe promise settles, and pipeThrough() hides that promise. That is why the read loop pipes port.readable into the decoder with pipeTo() and keeps the returned promise: close() cancels the outermost reader, cancellation travels back up the chain and cancels port.readable, and only when pipeDone settles is the stream unlocked. Calling port.close() before that point rejects with a TypeError about a locked stream, which is the most common Web Serial shutdown bug. The write queue exists for the same reason on the writable side.
Read errors: recoverable versus fatal¶
The specification defines which errors keep the port usable:
Error on readable | Meaning | After the error |
|---|---|---|
BufferOverrunError | Data arrived faster than it was read | port.readable returns a new stream; continue |
BreakError | Break condition on the line | New stream; continue |
FramingError | Stop bit not where expected (usually wrong baud rate) | New stream; fix settings |
ParityError | Parity check failed | New stream; continue or fix settings |
UnknownError | Unclassified OS error | New stream |
NetworkError | Port disconnected | port.readable is null; close and wait for connect |
Writes reject with NetworkError when the device disappears and with UnknownError for other OS failures.
Control signals: resetting boards from the browser¶
setSignals() drives output lines and getSignals() reads input lines:
| Direction | Member | RS-232 signal |
|---|---|---|
Output (setSignals) | dataTerminalReady | DTR |
| Output | requestToSend | RTS |
| Output | break | Break condition |
Input (getSignals) | dataCarrierDetect | DCD |
| Input | clearToSend | CTS |
| Input | ringIndicator | RI |
| Input | dataSetReady | DSR |
Many development boards wire DTR and RTS to the microcontroller's reset and boot-mode pins through a transistor pair, which is how desktop flashing tools reset a board into its bootloader. The same trick works from a PWA. The sequence below matches the "classic reset" used by common ESP32 tooling; boards with different auto-reset circuits need different timing, so treat it as an example to verify against your hardware.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Reset an ESP32-style board with the common two-transistor auto-reset circuit
// into its serial bootloader. The port must already be open.
export async function resetIntoBootloader(port) {
await port.setSignals({ dataTerminalReady: false, requestToSend: true }); // hold EN low
await sleep(100);
await port.setSignals({ dataTerminalReady: true, requestToSend: false }); // IO0 low, EN high
await sleep(50);
await port.setSignals({ dataTerminalReady: false }); // release IO0
}
Connection state, BYOB reads and workers¶
connectanddisconnectevents fire onnavigator.serialand bubble from eachSerialPort. Since Chrome 130,port.connectedreports the logical connection state, and Bluetooth RFCOMM ports also fire these events when the remote device connects or drops, without the port being opened.-
Since Chrome 106
port.readablesupports BYOB ("bring your own buffer") readers, useful for fixed-size binary frames without extra copies:byob-read.jsconst reader = port.readable.getReader({ mode: "byob" }); // Fill exactly 64 bytes, looping until the view is full. let view = new Uint8Array(64); let filled = 0; while (filled < view.byteLength) { const { value, done } = await reader.read(view.subarray(filled)); if (done) break; filled += value.byteLength; view = new Uint8Array(value.buffer); // the buffer was transferred; re-wrap it } reader.releaseLock(); -
navigator.serialis exposed in dedicated workers.requestPort()is window-only, butgetPorts()works in the worker, so the pattern is: grant in the page, then run the whole protocol in a worker.
Web Serial support in 2026¶
Web Serial has the broadest reach of the four transport APIs:
- Chromium desktop (Chrome, Edge, Opera) since version 89. Bluetooth RFCOMM ports since Chrome 117.
- Chrome for Android: from Chrome 138 limited to Bluetooth RFCOMM serial ports. MDN's compatibility data records full support, including wired ports, from Chrome 148, matching the "Web Serial API on Android" chromestatus entry.
- Firefox 151 and later on desktop: supported, with access gated by a synthetically generated site-permission add-on, the same mechanism Firefox uses for Web MIDI. Firefox for Android does not support it.
- Safari: not supported; WebKit opposes the API.
The specification document is published at wicg.github.io/serial (MDN's compatibility data now references serial.spec.whatwg.org, which serves the same document).
Web NFC¶
Web NFC reads and writes NDEF (NFC Data Exchange Format) messages on NFC tags held a few centimeters from the back of an Android phone. That covers the practical NFC use cases for a PWA: asset and inventory tags, equipment provisioning (writing a configuration or a deep link onto a tag), event check-in and access badges that carry an identifier, museum and retail "tap for details" tags, smart posters and physical game cards.
It deliberately stops at NDEF. Low-level tag technologies (ISO-DEP, NFC-A/B, NFC-F), peer-to-peer mode and host-based card emulation are not exposed, so a PWA cannot read payment cards, transit cards or passports, and cannot make the phone behave like a tag.
Android only
Web NFC ships only in Chrome for Android (since Chrome 89) and browsers built on it, such as Samsung Internet 15 and later. No desktop browser implements it, Mozilla and WebKit oppose it, and no browser on iOS or iPadOS exposes it. Treat every NFC feature as an Android-only enhancement with a manual-entry or QR-code fallback.
NDEF messages, records and record types¶
A tag stores one NDEF message. A message is a list of records, and each record has a type and a payload. Web NFC represents them as NDEFMessage (with a records array) and NDEFRecord objects. On reading, record.data is a DataView over the payload (or null), so text encoded in UTF-16 survives intact.
recordType | NFC Forum record | Payload when writing (data) | Extra members |
|---|---|---|---|
"empty" | Empty record | none | |
"text" | Text record | String or BufferSource | encoding ("utf-8" default; "utf-16", "utf-16le", "utf-16be" allowed), lang (defaults to the document language) |
"url" | URI record | URL string | |
"absolute-url" | Absolute-URI record | URL string | |
"mime" | MIME media record | BufferSource | mediaType, for example "application/json" |
"smart-poster" | Smart Poster | NDEFMessageInit (nested records) | |
"unknown" | Unknown type | BufferSource | |
"example.com:type" | External type (your domain, a colon, a type name) | BufferSource or nested NDEFMessageInit | |
":t", ":act" ... | Local type, only valid inside a parent record | Depends on the parent |
Every record can also carry an id (a URL used as the record identifier). record.toRecords() parses a nested message out of a smart poster or external-type record and returns null when the payload is not a valid NDEF message. Nesting is limited to a depth of 32; deeper structures throw TypeError when you write.
The write() method also accepts shortcuts: a plain string becomes a single text record and a BufferSource becomes a single MIME record of type application/octet-stream.
scan(), write() and makeReadOnly()¶
NDEFReader is the only entry point. The same object scans, writes and locks tags, and all three methods require the "nfc" permission.
| Method | Options (defaults) | Resolves when | Rejects with |
|---|---|---|---|
scan(options) | { signal } | Scanning has started (not when a tag is read) | InvalidStateError (not the top-level document, or this reader is already scanning), NotAllowedError (permission denied), NotSupportedError (no NFC adapter), NotReadableError (the browser cannot use the adapter, typically because NFC is switched off), the signal's abort reason |
write(message, options) | { overwrite: true, signal } | A tag was tapped and the message was written | The same permission and adapter errors, plus NotAllowedError when overwrite is false and the tag already holds records, NotSupportedError when the tag cannot store NDEF, NetworkError when the transfer fails, TypeError for an invalid message, AbortError when a newer write() replaces it or the page is hidden before the transfer starts |
makeReadOnly(options) | { signal } (Chrome 100) | A tapped tag was permanently locked | The same set as write() |
Three behaviors shape how you build the UI:
- Reads arrive as events, not return values. After
scan()resolves, each tap firesreading(anNDEFReadingEventwithserialNumberandmessage). A tag that is in range but not NDEF-readable firesreadingerrorinstead. write()waits for a tap indefinitely. The promise stays pending until the user holds a tag to the phone, so always pass asignal(for exampleAbortSignal.timeout(30_000)) and show a cancel button. When a timeout signal aborts the call, the rejection is the signal's reason: aTimeoutErrorDOMException, not anAbortError.- One pending write per document. Calling
write()again aborts the previous pending write withAbortError. When the page becomes hidden, the browser suspends NFC and aborts pending writes and lock operations that have not started transferring.
serialNumber is the tag's UID as a string of hex bytes, or an empty string when the tag has none. The specification warns that not all tags have a stable UID and some generate a random one on every read, so never treat it as a secure identifier.
Permission model and visibility¶
Web NFC uses a normal permission prompt, not a chooser. The first scan(), write() or makeReadOnly() must run inside a user gesture so the browser can show the prompt; once the user grants "nfc", later calls work without a gesture, which lets you start scanning on page load for returning users. The same permission covers reading and writing.
Beyond the permission, the platform limits when NFC works:
- Only the top-level document can use it. There is no Permissions Policy token to delegate, and calls from an iframe reject with
InvalidStateError. - The page must be visible and the screen on and unlocked. Hidden pages have NFC suspended and it resumes automatically when the page becomes visible again.
- When no scan is active, Android handles a tapped tag itself (for example by opening a URL record in the default browser), which is why an NFC screen in your PWA should keep a scan running while it is open.
A complete NFC tag module¶
// Web NFC helper: scanning with typed record decoding, guarded writes and locking.
// Chrome for Android only. The first call to startScan()/write() must run inside a
// user gesture unless permissionState() already reports "granted".
const bytesOf = (view) =>
view ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength) : new Uint8Array();
export function decodeRecord(record) {
switch (record.recordType) {
case "empty":
return { type: "empty" };
case "text":
// encoding is "utf-8" or "utf-16be" on read; TextDecoder understands both labels.
return {
type: "text",
lang: record.lang,
text: new TextDecoder(record.encoding ?? "utf-8").decode(record.data),
};
case "url":
case "absolute-url":
return { type: "url", url: new TextDecoder().decode(record.data) };
case "mime":
if (record.mediaType === "application/json") {
return { type: "json", value: JSON.parse(new TextDecoder().decode(record.data)) };
}
return { type: "mime", mediaType: record.mediaType, bytes: bytesOf(record.data) };
case "smart-poster":
return { type: "smart-poster", records: (record.toRecords() ?? []).map(decodeRecord) };
default: {
// External types ("example.com:asset") may contain a nested message or raw bytes.
let nested = null;
try {
nested = record.toRecords(); // null when the payload is not an NDEF message
} catch {
nested = null; // NotSupportedError for types that cannot nest
}
return {
type: record.recordType.includes(":") ? "external" : "unknown",
recordType: record.recordType,
records: nested ? nested.map(decodeRecord) : null,
bytes: bytesOf(record.data),
};
}
}
}
// Translate DOMException names into messages a user can act on.
function explain(error) {
const messages = {
NotAllowedError: "NFC permission was denied, or the tag already holds data.",
NotSupportedError: "This device has no NFC reader, or the tag cannot store NDEF data.",
NotReadableError: "NFC is turned off. Enable it in Android settings and try again.",
InvalidStateError: "NFC is only available in the top-level page.",
NetworkError: "The tag moved away during the transfer. Hold it still and retry.",
TimeoutError: "No tag was detected in time.",
AbortError: "The NFC operation was canceled.",
};
return new Error(messages[error.name] ?? error.message, { cause: error });
}
export class NfcSession extends EventTarget {
#reader = new NDEFReader();
#scanController = null;
#writing = false;
static get supported() {
return "NDEFReader" in globalThis;
}
static async permissionState() {
try {
return (await navigator.permissions.query({ name: "nfc" })).state;
} catch {
return "prompt"; // engines that do not know the "nfc" permission name reject
}
}
constructor() {
super();
this.#reader.addEventListener("reading", ({ serialNumber, message }) => {
// A tap during a pending write also runs the reading steps: the event would
// carry the tag's OLD content, so suppress it while writing.
if (this.#writing) return;
this.dispatchEvent(
new CustomEvent("tag", {
detail: { serialNumber, records: message.records.map(decodeRecord) },
}),
);
});
this.#reader.addEventListener("readingerror", () => {
// In range but not NDEF-readable, for example a bank card.
this.dispatchEvent(new Event("unreadable"));
});
}
async startScan() {
if (this.#scanController) return; // a second scan() on this reader would reject
const controller = new AbortController();
try {
await this.#reader.scan({ signal: controller.signal });
this.#scanController = controller;
} catch (error) {
throw explain(error);
}
}
stopScan() {
this.#scanController?.abort();
this.#scanController = null;
}
// records: NDEFRecordInit[]; lock: make the tag permanently read-only afterwards.
async write(records, { overwrite = true, lock = false, timeoutMs = 30_000 } = {}) {
const signal = AbortSignal.timeout(timeoutMs);
this.#writing = true;
try {
await this.#reader.write({ records }, { overwrite, signal });
// Irreversible. makeReadOnly() waits for a tag in range just like write(), so keep
// the "hold the tag" prompt visible; the shared signal bounds both waits.
if (lock) await this.#reader.makeReadOnly({ signal });
} catch (error) {
throw explain(error);
} finally {
this.#writing = false;
}
}
}
Wiring it up shows the gesture rule and the "resume for returning users" pattern:
import { NfcSession } from "./nfc-tags.js";
const nfc = NfcSession.supported ? new NfcSession() : null;
const encoder = new TextEncoder();
if (!nfc) {
showManualEntryFallback(); // desktop, iOS, Firefox
} else {
nfc.addEventListener("tag", ({ detail }) => renderTag(detail));
nfc.addEventListener("unreadable", () => showToast("That tag is not supported."));
// Permission already granted: scanning may start without a gesture.
if ((await NfcSession.permissionState()) === "granted") await nfc.startScan();
document.querySelector("#scan").addEventListener("click", async () => {
try {
await nfc.startScan(); // first call shows the permission prompt
showToast("Hold a tag against the back of your phone.");
} catch (error) {
showToast(error.message);
}
});
document.querySelector("#provision").addEventListener("click", async () => {
try {
await nfc.write(
[
{ recordType: "url", data: "https://assets.example.com/a/4711" },
{
recordType: "example.com:asset", // external type under a domain you control
data: encoder.encode(JSON.stringify({ id: 4711, rev: 3 })),
},
],
{ overwrite: false }, // refuse to clobber a tag that already holds data
);
showToast("Tag written.");
} catch (error) {
showToast(error.message);
}
});
}
The URL record makes the tag useful even without your PWA open (Android opens the link), while the external-type record carries structured data only your app interprets.
Security considerations for tags¶
- Tags are untrusted input. Anyone can rewrite an unlocked tag, and a tag's URL can point anywhere. Validate every record, never navigate to a URL from a tag without showing it to the user first, and render text with
textContent, neverinnerHTML. - Lock tags you deploy in public.
makeReadOnly()is permanent, which is exactly what you want for tags stuck to equipment or posters, and exactly what you do not want on reusable tags. - Serial numbers are not credentials. UIDs can be cloned or randomized. If a tag must prove authenticity, sign the payload and verify the signature in your app or on your server.
- Blocklisted devices. The specification defines a blocklist of NFC devices, identified by their historical bytes, that pages may not access.
Web NFC support¶
Chrome for Android 89 shipped scan() and write(); makeReadOnly() followed in Chrome 100, so detect it separately with "makeReadOnly" in NDEFReader.prototype. Samsung Internet 15 and later supports the API. Desktop Chrome does not expose NDEFReader at all, even on laptops with NFC hardware. To test, use a physical Android device with NFC enabled and debug it through chrome://inspect remote debugging, and note that installing the PWA changes nothing about NFC behavior.
Geolocation¶
The Geolocation API reports the device's position in WGS84 coordinates, computed by the platform from GNSS (GPS and friends), Wi-Fi, cell towers or the IP address. It is the oldest device API on this page and the only one that works in every engine, which makes it the default choice for store finders, delivery and field-service apps, check-ins, geotagging photos and foreground fitness or navigation tracking. What it cannot do is follow the user while the PWA is in the background; that limit drives most architectural decisions in location-aware PWAs.
getCurrentPosition(), watchPosition() and clearWatch()¶
The API predates promises and uses callbacks:
navigator.geolocation.getCurrentPosition(successCallback, errorCallback?, options?);
const watchId = navigator.geolocation.watchPosition(successCallback, errorCallback?, options?);
navigator.geolocation.clearWatch(watchId);
getCurrentPosition() delivers one position. watchPosition() delivers a position and then a new one whenever the implementation decides the position changed significantly; the threshold and any rate limiting are implementation-defined. It returns a positive integer ID, or 0 when the document is not fully active. Always pass an error callback: without one, failures are silent.
The specification's request algorithm explains several behaviors developers otherwise find surprising:
flowchart TD
A[getCurrentPosition or watchPosition] --> B{"Allowed by policy, secure context?"}
B -->|No| D1[PERMISSION_DENIED]
B -->|Yes| C{"Document visible?"}
C -->|No| W[Wait until visible]
W --> P
C -->|Yes| P{Permission}
P -->|denied| D1
P -->|granted| Q{"Cached position within maximumAge, same accuracy mode?"}
Q -->|Yes| R[Success with cached position]
Q -->|No| T["Start timeout, acquire position"]
T -->|fix| S["Success, position cached"]
T -->|timeout elapsed| D3[TIMEOUT]
T -->|failure| D2[POSITION_UNAVAILABLE] PositionOptions in detail¶
| Member | Default | Meaning |
|---|---|---|
enableHighAccuracy | false | Hint to use the most accurate source (usually GNSS). Slower first fix and more power. A cached position is reused only if it was acquired with the same enableHighAccuracy value. |
timeout | 0xFFFFFFFF ms (effectively never) | Maximum time for acquiring the position. Time spent waiting for the page to become visible and for the user to answer the permission prompt does not count. 0 can fail immediately. |
maximumAge | 0 | Accept a cached position no older than this many milliseconds. 0 forces a fresh fix; Infinity (clamped to the maximum) accepts any cached position. |
Both numeric members use WebIDL [Clamp], so negative values become 0 and huge values saturate instead of throwing.
A pragmatic pattern for "where am I roughly, right now" is a cheap first request followed by a precise one: { maximumAge: 300_000, timeout: 5_000 } answers instantly from the cache when it can, and a subsequent { enableHighAccuracy: true } request refines the result.
What a position contains¶
GeolocationPosition has coords and timestamp (milliseconds since the epoch, when the position was acquired). GeolocationCoordinates holds:
| Attribute | Unit | Notes |
|---|---|---|
latitude, longitude | Degrees, WGS84 | Always present |
accuracy | Meters | Radius of the 95% confidence circle. Always present. |
altitude | Meters above the WGS84 ellipsoid | null when unavailable (common with Wi-Fi fixes) |
altitudeAccuracy | Meters, 95% confidence | null when unavailable |
heading | Degrees clockwise from true north | Direction of travel, not where the phone points; null when unavailable or stationary |
speed | Meters per second | null when unavailable |
Both interfaces gained toJSON() (Chrome 126, Firefox 129, Safari 18). Before that, JSON.stringify(position) produced {} because the attributes live on the prototype, a classic source of empty rows in analytics and IndexedDB. Copy the fields by hand for older browsers.
accuracy is the number to show and to filter on. A fix with an accuracy of several hundred meters or more is not a GPS fix, and it may be deliberately coarse: both iOS ("Precise Location" switched off) and Android (the "Approximate" location option) let the user give the browser only approximate location, and the page cannot tell the difference except through accuracy.
Experimental: approximate location requests
Chromium is working on an accuracyMode option ("precise" by default, or "approximate") that lets a site ask for coarse location only. It has not shipped in a stable release as of September 2026; Mozilla's standards position is negative and WebKit's is support. Do not depend on it yet.
Errors¶
The error callback receives a GeolocationPositionError with a numeric code and a human-readable (not localized for your UI) message:
code | Constant | When |
|---|---|---|
1 | PERMISSION_DENIED | The user or the OS denied location, the geolocation Permissions Policy blocks the document, or the context is not secure. A browser-level grant does not help when the OS denied location to the browser app itself. |
2 | POSITION_UNAVAILABLE | Acquisition failed (no signal, location services unavailable), or the document is not fully active |
3 | TIMEOUT | timeout elapsed during acquisition |
A TIMEOUT does not end a watch: the watch stays registered and keeps trying, so treat it as "still searching", not as a fatal error. PERMISSION_DENIED is final until the user changes the setting.
Permission model¶
Geolocation uses a standard permission prompt, keyed by origin, with the permission name "geolocation". navigator.permissions.query({ name: "geolocation" }) works in Chrome, Firefox 46 and later, and Safari 16 and later, and its change event tells you when the user flips the setting.
- No user activation is required by the API, which is why sites abuse it on page load. Chromium punishes that: after three dismissals of a permission prompt, the origin is embargoed and the permission is automatically blocked for seven days (constants in Chromium's permission auto-blocker). Ask from a button whose label explains the benefit.
- Denial is sticky. Script cannot re-prompt a denied origin; the user must change site settings. The
<geolocation>element below exists largely to give users a recovery path. - Two layers of permission on mobile. Android and iOS grant location to the browser app (or to the TWA wrapper), and the browser grants it to your origin. Either layer can refuse.
- Iframes need
allow="geolocation"; the default allowlist is'self'.
The <geolocation> element¶
Chrome 144 (desktop and Android) shipped <geolocation>, a browser-rendered control that combines the permission prompt and the position request into a single, clearly user-initiated click. It evolved from the earlier <permission> element origin trial. Because the browser draws the button and verifies the click, it can prompt again even after a previous denial.
| Part | Details |
|---|---|
| Attributes | autolocate (fetch immediately on render if permission was already granted), watch (update continuously, like watchPosition()) |
| Events | location (data or an error arrived), promptaction (user chose an option in the dialog), promptdismiss (user closed the dialog), validationstatuschange |
| Properties | position (GeolocationPosition), error (GeolocationPositionError), isValid, invalidReason |
| Constraints | At most three per page; with a fourth, every <geolocation> element on the page is disabled. The element is deactivated if its styling could mislead: text and background need a contrast ratio of at least 3:1, the font size may not go below small, and properties such as opacity are forced to safe values. |
<geolocation autolocate>
<!-- Fallback content renders only where <geolocation> is unsupported -->
<button id="locate-fallback" type="button">Use my location</button>
</geolocation>
<p id="location-status" role="status"></p>
<script type="module">
const status = document.querySelector("#location-status");
const show = (position) =>
loadNearbyStores(position.coords.latitude, position.coords.longitude);
if (typeof HTMLGeolocationElement === "function") {
const geo = document.querySelector("geolocation");
geo.addEventListener("location", () => {
if (geo.position) show(geo.position);
else if (geo.error) status.textContent = `Location unavailable: ${geo.error.message}`;
});
geo.addEventListener("promptdismiss", () => {
status.textContent = "Press the location button again to find stores near you.";
});
geo.addEventListener("validationstatuschange", () => {
// Invalid means blocked, for example covered by another element or badly styled.
if (!geo.isValid) console.warn("geolocation element blocked:", geo.invalidReason);
});
} else {
document.querySelector("#locate-fallback").addEventListener("click", () => {
navigator.geolocation.getCurrentPosition(show, (error) => {
status.textContent = `Location unavailable: ${error.message}`;
}, { maximumAge: 300_000, timeout: 10_000 });
});
}
</script>
Mozilla's position on the element is positive; WebKit has not stated one. The fallback branch is therefore not optional.
Background limits: what happens when the PWA is hidden¶
This is the hard constraint of web geolocation. The specification delivers position updates only to fully active, visible documents:
- A request made while the page is hidden waits until it becomes visible.
- Watch updates that occur while the page is hidden are dropped, not queued. When the page returns, updates resume from the current position; there is no location history to catch up on.
- Service workers have no
navigator.geolocation, and neither Background Sync nor Periodic Background Sync can obtain a position. - Mobile operating systems suspend backgrounded browsers and standalone PWAs quickly, and iOS does so almost immediately. See iOS & iPadOS and Android.
Design around it: record track segments with explicit gaps, keep the screen on during active navigation with a Screen Wake Lock, and move true background tracking (runners, couriers) to a native app or a Trusted Web Activity that pairs the PWA with native code.
A complete foreground location tracker¶
// Foreground tracking with accuracy filtering, jitter suppression and honest gaps.
const EARTH_RADIUS_M = 6_371_008.8;
export function distanceMeters(a, b) {
const rad = (deg) => (deg * Math.PI) / 180;
const dLat = rad(b.latitude - a.latitude);
const dLon = rad(b.longitude - a.longitude);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(rad(a.latitude)) * Math.cos(rad(b.latitude)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h)); // haversine
}
// toJSON() exists only in recent engines; copy by hand otherwise.
export function toPlain(position) {
if (typeof position.toJSON === "function") return position.toJSON();
const { latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed } =
position.coords;
return {
timestamp: position.timestamp,
coords: { latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed },
};
}
// Promise wrapper for one-off requests.
export function getPosition(options = {}) {
return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) {
reject(new Error("Geolocation unavailable (insecure context or unsupported browser)"));
return;
}
navigator.geolocation.getCurrentPosition(resolve, reject, {
timeout: 15_000,
maximumAge: 60_000,
...options,
});
});
}
export class LocationTracker extends EventTarget {
#watchId = null;
#last = null;
#maxAccuracyM;
#minMoveM;
constructor({ maxAccuracyM = 50, minMoveM = 5 } = {}) {
super();
this.#maxAccuracyM = maxAccuracyM;
this.#minMoveM = minMoveM;
document.addEventListener("visibilitychange", () => {
if (this.#watchId === null) return;
// Updates are dropped while hidden: mark a gap instead of drawing a straight line later.
const type = document.visibilityState === "hidden" ? "paused" : "resumed";
this.dispatchEvent(new CustomEvent(type, { detail: { at: Date.now() } }));
if (type === "resumed") this.#last = null; // next fix starts a new segment
});
}
get active() {
return this.#watchId !== null;
}
start() {
if (this.#watchId !== null) return;
this.#watchId = navigator.geolocation.watchPosition(
(position) => this.#onPosition(position),
(error) => this.#onError(error),
{ enableHighAccuracy: true, timeout: 20_000, maximumAge: 0 },
);
}
stop() {
if (this.#watchId === null) return;
navigator.geolocation.clearWatch(this.#watchId);
this.#watchId = null;
this.#last = null;
}
#onPosition(position) {
const { coords } = position;
if (coords.accuracy > this.#maxAccuracyM) {
// Wi-Fi/cell fix, or the user granted only approximate location.
this.dispatchEvent(new CustomEvent("coarse", { detail: toPlain(position) }));
return;
}
if (this.#last) {
const moved = distanceMeters(this.#last.coords, coords);
// Ignore movement smaller than the error radius: it is noise, not travel.
if (moved < Math.max(this.#minMoveM, coords.accuracy / 2)) return;
}
this.#last = position;
this.dispatchEvent(new CustomEvent("position", { detail: toPlain(position) }));
}
#onError(error) {
switch (error.code) {
case GeolocationPositionError.PERMISSION_DENIED:
this.stop(); // nothing will ever arrive; free the watch and explain the settings path
this.dispatchEvent(new CustomEvent("denied", { detail: error.message }));
break;
case GeolocationPositionError.TIMEOUT:
this.dispatchEvent(new Event("searching")); // the watch keeps trying
break;
default:
this.dispatchEvent(new CustomEvent("unavailable", { detail: error.message }));
}
}
}
Store toPlain() results in IndexedDB as they arrive; if the page is killed while hidden, the segments recorded so far survive.
Generic Sensor APIs¶
The Generic Sensor API is a small framework (the Sensor base class, its lifecycle, events and privacy rules) plus a family of concrete sensor specifications. Compared with the older orientation and motion events, it lets you choose a sampling frequency, gives every reading a high-resolution timestamp, defines coordinate systems precisely, reports failures per sensor through an error event, and can remap axes to the screen. PWAs use it for shake and gesture detection, spirit levels and inclinometers, 3D model and panorama viewers that follow the device, lightweight AR overlays, stabilization and motion-controlled games.
The sensor classes¶
| Class | Reading | Unit | Permissions / policy tokens | Chromium |
|---|---|---|---|---|
Accelerometer | x, y, z, including gravity | m/s² | accelerometer | 67 |
LinearAccelerationSensor | x, y, z, gravity removed | m/s² | accelerometer | 67 |
GravitySensor | x, y, z, gravity only | m/s² | accelerometer | 91 |
Gyroscope | x, y, z angular velocity | rad/s | gyroscope | 67 |
AbsoluteOrientationSensor | quaternion relative to Earth's frame | unit quaternion | accelerometer, gyroscope, magnetometer | 67 |
RelativeOrientationSensor | quaternion relative to an arbitrary start frame | unit quaternion | accelerometer, gyroscope | 67 |
Magnetometer | x, y, z magnetic field | µT | magnetometer | 🧪 flag |
AmbientLightSensor | illuminance | lux | ambient-light-sensor | 🧪 flag |
Magnetometer and AmbientLightSensor sit behind chrome://flags/#enable-generic-sensor-extra-classes. The ambient light specification requires readings to be rounded to a multiple of at least 50 lux, and the accelerometer specification quantizes readings to 0.1 m/s², both to limit fingerprinting and side channels.
All classes are [SecureContext, Exposed=Window]: no workers, no service workers.
Lifecycle: construct, start, read, stop¶
stateDiagram-v2
[*] --> idle: new Accelerometer(options)
idle --> activating: start()
activating --> activated: permission granted and sensor connected, fires activate
activating --> idle: error event (NotAllowedError or NotReadableError)
activated --> activated: reading events
activated --> idle: stop() or error event - Construction throws synchronously:
SecurityErrorwhen a Permissions Policy blocks the sensor, and aReferenceErrorfrom your own code when the class does not exist at all. start()returns nothing. Permission and hardware problems arrive later as anerrorevent whoseerrorisNotAllowedError(permission denied) orNotReadableError(the platform sensor cannot be connected, for example on a laptop without a gyroscope). On success,activatefires and thenreadingevents.- Attributes:
activated,hasReading,timestamp(aDOMHighResTimeStampon the page's time origin) and the sensor-specific values, which arenulluntil the first reading. - Options:
frequencyin Hz (a request, not a promise) and, for motion and orientation sensors,referenceFrame:"device"(default) or"screen", which remaps the axes to the current screen orientation so "left" stays left when the user rotates the phone.
Frequency, visibility and focus rules¶
The requested frequency is clamped by the platform and by the browser. Chromium caps motion and orientation sensors at 60 Hz and Magnetometer and AmbientLightSensor at 10 Hz (sensor_traits.h in Chromium's device service). Asking for 200 Hz silently gives you 60.
Readings are delivered only while the document is visible and the focused frame is same-origin with the sensor's document. If the user focuses a cross-origin iframe (an embedded payment form, for example), the parent page stops receiving readings, which prevents inferring keystrokes from device motion. Your code should expect gaps and never assume a steady stream.
Permissions and Permissions Policy¶
The permission names are "accelerometer", "gyroscope", "magnetometer" and "ambient-light-sensor", queryable with navigator.permissions.query(). Chrome has historically granted motion sensors automatically through its "Motion sensors" site setting. It is changing that setting from Allow/Block to Allow/Ask/Block, first keeping Allow as the default and later switching to Ask, at which point sensors and orientation events will require a prompt (see the next section for requestPermission()).
Every token has a default allowlist of 'self'. A cross-origin iframe needs explicit delegation, and AbsoluteOrientationSensor needs all three motion tokens:
<iframe src="https://viewer.example.net/model/42"
allow="accelerometer; gyroscope; magnetometer"></iframe>
A complete motion module: shake detection and device orientation¶
// Generic Sensor helpers with permission pre-checks and complete error handling.
export async function sensorPermissions(names) {
const entries = await Promise.all(
names.map(async (name) => {
try {
return [name, (await navigator.permissions.query({ name })).state];
} catch {
return [name, "unsupported"]; // unknown permission name in this engine
}
}),
);
return Object.fromEntries(entries);
}
function createSensor(Ctor, options, onError) {
try {
return new Ctor(options);
} catch (error) {
// SecurityError: the sensor's Permissions Policy feature is not allowed here.
onError(error);
return null;
}
}
// Fires "shake" when linear acceleration exceeds a threshold several times within a window.
export class ShakeDetector extends EventTarget {
#sensor = null;
#peaks = [];
constructor({ threshold = 12, peaks = 3, windowMs = 800 } = {}) {
super();
if (!("LinearAccelerationSensor" in globalThis)) return;
this.#sensor = createSensor(LinearAccelerationSensor, { frequency: 60 }, (error) =>
queueMicrotask(() => this.dispatchEvent(new CustomEvent("error", { detail: error }))),
);
if (!this.#sensor) return;
this.#sensor.addEventListener("reading", () => {
const { x, y, z, timestamp } = this.#sensor;
if (Math.hypot(x, y, z) < threshold) return;
this.#peaks = this.#peaks.filter((t) => timestamp - t < windowMs);
this.#peaks.push(timestamp);
if (this.#peaks.length >= peaks) {
this.#peaks = [];
this.dispatchEvent(new Event("shake"));
}
});
this.#sensor.addEventListener("error", ({ error }) => {
// NotAllowedError (permission) or NotReadableError (no hardware): fall back to a button.
this.dispatchEvent(new CustomEvent("error", { detail: error }));
});
}
get supported() {
return this.#sensor !== null;
}
start() {
this.#sensor?.start();
}
stop() {
this.#sensor?.stop();
this.#peaks = [];
}
}
// Streams a 4x4 rotation matrix for a WebGL or CSS 3D scene.
export function watchOrientation({ absolute = false, frequency = 60, onMatrix, onError }) {
const Ctor = absolute ? globalThis.AbsoluteOrientationSensor : globalThis.RelativeOrientationSensor;
if (!Ctor) return null; // caller falls back to deviceorientation events
const sensor = createSensor(Ctor, { frequency, referenceFrame: "screen" }, onError);
if (!sensor) return null;
const matrix = new Float32Array(16); // reused: no allocation per reading
sensor.addEventListener("reading", () => {
sensor.populateMatrix(matrix); // throws NotReadableError before the first reading
onMatrix(matrix, sensor.timestamp);
});
sensor.addEventListener("error", ({ error }) => onError(error));
sensor.start();
return () => sensor.stop();
}
Two details in that code are easy to miss. populateMatrix() accepts a Float32Array or Float64Array of at least 16 elements, or a DOMMatrix, and throws TypeError for a shorter array. And construction errors are reported asynchronously through the same error channel as runtime errors, so the UI has one code path for "motion input is unavailable".
Generic Sensor support¶
The motion and orientation classes shipped in Chrome 67 on desktop and Android (with the matching Edge, Opera and Samsung Internet releases); GravitySensor arrived in Chrome 91. Firefox and Safari do not implement any of them and both vendors oppose the API, so on those browsers use the device orientation and motion events described next. Desktop Chromium exposes the classes on machines without sensors too; expect NotReadableError there. Chrome DevTools can emulate orientation (More tools > Sensors), and automated tests can use the Generic Sensors WebDriver extension commands (Chrome 120) to inject virtual sensor readings; see Automated Testing.
DeviceOrientation and DeviceMotion events¶
The orientation and motion events are the older, event-based way to read the same sensors, and the only one that works in Firefox and Safari. They power tilt-to-steer games, parallax effects, compass views, shake-to-undo and simple AR experiences. They offer no frequency control and no per-sensor errors, and since 2019 their specification requires explicit permission, which Safari on iOS enforces and Chromium has now started to implement.
Event reference¶
Event (on window) | Interface | Data | Notes |
|---|---|---|---|
deviceorientation | DeviceOrientationEvent | alpha [0, 360), beta [-180, 180), gamma [-90, 90) in degrees, absolute | Relative orientation in Chromium since Chrome 50 (earlier versions reported absolute values) |
deviceorientationabsolute | DeviceOrientationEvent | Same, with absolute: true (Earth frame, needs a magnetometer) | Chromium 50, Firefox 110; not in Safari |
devicemotion | DeviceMotionEvent | acceleration, accelerationIncludingGravity (m/s²), rotationRate (alpha, beta, gamma in deg/s), interval (ms) | Any member can be null when the hardware lacks it |
The angles are intrinsic Tait-Bryan rotations in Z-X'-Y'' order applied to the device frame: x to the right of the screen, y toward the top, z out of the screen, all in the device's natural (usually portrait) orientation. Three consequences trip people up:
- The frame does not rotate with the screen. In landscape, "tilt left/right" is
beta, notgamma. Compensate withscreen.orientation.angle, as the code below does. alphais not a compass heading. It increases counter-clockwise, so a heading is360 - alpha, and only when the data is absolute. On iOS, WebKit instead exposeswebkitCompassHeadingandwebkitCompassAccuracyon the event and does not exposeabsoluteat all.rotationRateis in degrees per second, while theGyroscopeclass reports radians per second.
The specification also limits precision to 0.1 degree, 0.1 deg/s and 0.1 m/s² as a fingerprinting defense, and all three events require a secure context (Chrome removed insecure access in Chrome 76).
requestPermission(): iOS first, Chromium now too¶
The Device Orientation specification adds two static methods:
DeviceOrientationEvent.requestPermission(absolute = false); // Promise<"granted" | "denied">
DeviceMotionEvent.requestPermission(); // Promise<"granted" | "denied">
Their algorithm is precise about user activation: if any required permission is still in the "prompt" state and the call is not made during transient activation, the promise rejects with NotAllowedError. DeviceOrientationEvent.requestPermission() asks for "accelerometer" and "gyroscope", plus "magnetometer" when absolute is true; devicemotion requires "accelerometer" and "gyroscope". Events simply never fire for a page that lacks the permissions, with no error anywhere.
| Browser | Behavior |
|---|---|
| Safari on iOS and iPadOS | The methods exist and are mandatory: without a "granted" result, no orientation or motion events arrive. WebKit's version takes no absolute argument. |
| Chrome and Edge 151+ | The methods exist (Chrome's release notes list them in Chrome 151; MDN's compatibility data records 152). Chrome is moving its "Motion sensors" setting to Allow/Ask/Block, first with Allow as the default, so for most users the call currently resolves "granted" without a prompt. Once the default becomes Ask, listeners receive nothing until the call succeeds. Enterprises can control it with the DefaultSensorsSetting policy. |
| Firefox | No methods; events fire without a prompt. |
The portable rule: feature-detect requestPermission, call both methods from the same click handler before any other await, and start listening only after "granted".
A complete tilt controller¶
// Cross-browser tilt input: permission handling, screen-orientation compensation,
// calibration and a watchdog for devices that never deliver events.
export async function requestMotionAccess({ absolute = false } = {}) {
// Call from a click/touchend handler. Both requests start synchronously so they
// share the same transient activation. globalThis.* avoids ReferenceErrors on
// engines without these interfaces.
const requests = [];
const Motion = globalThis.DeviceMotionEvent;
const Orientation = globalThis.DeviceOrientationEvent;
if (typeof Motion?.requestPermission === "function") requests.push(Motion.requestPermission());
if (typeof Orientation?.requestPermission === "function") {
requests.push(Orientation.requestPermission(absolute)); // WebKit ignores the argument
}
if (requests.length === 0) return Orientation ? "granted" : "unsupported";
try {
const states = await Promise.all(requests);
return states.every((state) => state === "granted") ? "granted" : "denied";
} catch (error) {
if (error.name === "NotAllowedError") return "needs-gesture"; // called outside a click
throw error;
}
}
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
export class TiltInput extends EventTarget {
#onEvent = (event) => this.#handle(event);
#neutral = null;
#latest = null;
#watchdog = 0;
#range;
constructor({ rangeDegrees = 30 } = {}) {
super();
this.#range = rangeDegrees;
}
start() {
window.addEventListener("deviceorientation", this.#onEvent);
// No event within a second: no sensor, no permission, or a Permissions Policy block.
this.#watchdog = setTimeout(() => {
if (!this.#latest) this.dispatchEvent(new Event("unavailable"));
}, 1000);
}
stop() {
window.removeEventListener("deviceorientation", this.#onEvent);
clearTimeout(this.#watchdog);
this.#latest = null;
}
// Treat the current pose as "centered", e.g. how the player happens to hold the phone.
calibrate() {
this.#neutral = this.#latest;
}
#handle({ beta, gamma }) {
if (beta === null || gamma === null) return; // desktops may fire once with nulls
// Map device-frame angles to screen-frame tilt. Valid for devices held within
// roughly 45 degrees of flat; near vertical, Euler angles degenerate.
const angle = ((screen.orientation?.angle ?? window.orientation ?? 0) + 360) % 360;
const [x, y] =
angle === 90 ? [beta, -gamma] :
angle === 180 ? [-gamma, -beta] :
angle === 270 ? [-beta, gamma] :
[gamma, beta];
this.#latest = { x, y };
this.#neutral ??= this.#latest; // first reading becomes the default center
this.dispatchEvent(
new CustomEvent("tilt", {
detail: {
x: clamp((x - this.#neutral.x) / this.#range, -1, 1), // -1 left ... 1 right
y: clamp((y - this.#neutral.y) / this.#range, -1, 1), // -1 away ... 1 toward user
},
}),
);
}
}
import { requestMotionAccess, TiltInput } from "./tilt-input.js";
const tilt = new TiltInput();
tilt.addEventListener("tilt", ({ detail }) => steer(detail.x, detail.y));
tilt.addEventListener("unavailable", () => showTouchControls());
document.querySelector("#enable-tilt").addEventListener("click", async () => {
const state = await requestMotionAccess(); // first await in the handler
if (state === "granted") tilt.start();
else showTouchControls(); // denied, unsupported or no gesture
});
The watchdog matters because this API has no error channel: a denied permission, a blocked iframe and a desktop without sensors all look identical, namely silence.
Gamepad API¶
The Gamepad API exposes game controllers connected over USB or Bluetooth: Xbox, PlayStation and Switch Pro style pads, arcade sticks, flight and racing peripherals and accessibility controllers. Every engine ships it, including Safari on iOS and iPadOS, which makes it the most portable hardware API after Geolocation. Beyond games, PWAs use it for emulators, cloud-gaming and remote-play clients, media-center style navigation on TVs and handhelds, presentation clickers and switch-access input.
Exposure rules: nothing until the user presses a button¶
Gamepads are not listed until the user interacts with one. The specification defines a gamepad user gesture: pressing a button that has previously reported "not pressed", or moving an axis past a threshold from its neutral position. Until that happens, navigator.getGamepads() returns an empty list and gamepadconnected does not fire. This is a fingerprinting defense; it also means you cannot show "Controller detected" on page load. Show "Press any button on your controller" instead.
After exposure:
navigator.getGamepads()returns a sequence indexed bygamepad.index, withnullfor empty slots. An index stays stable while the controller remains connected.gamepadconnectedandgamepaddisconnectedfire onwindowwith aGamepadEventwhosegamepadproperty identifies the controller. Theongamepadconnected/ongamepaddisconnectedhandler attributes arrived late in Chrome (143); useaddEventListener()for older versions.- The returned
Gamepadobjects are snapshots in Chromium: readgetGamepads()again on every frame instead of holding on to an object and expecting it to update.
Chrome 103 made the API secure-context-only and added the gamepad Permissions Policy feature (default 'self'). When the policy disallows it, getGamepads() throws SecurityError and no connection events fire.
The Gamepad object and the standard mapping¶
| Member | Meaning |
|---|---|
id | Driver-provided description, often including vendor and product IDs; its format differs between browsers |
index | Slot in the getGamepads() sequence |
connected | false after disconnection |
timestamp | DOMHighResTimeStamp of the last data change; compare it to skip unchanged frames |
mapping | "standard" when the browser remapped the device to the standard layout, otherwise "" |
axes | Array of doubles in [-1, 1] |
buttons | Array of GamepadButton with pressed, touched and value (0 to 1 for analog triggers) |
vibrationActuator | GamepadHapticActuator for rumble, when supported |
When mapping is "standard", indices have fixed meanings:
| Index | Button | Index | Button |
|---|---|---|---|
| 0 | Bottom face button (A / Cross) | 9 | Right center (Start / Options) |
| 1 | Right face button (B / Circle) | 10 | Left stick press |
| 2 | Left face button (X / Square) | 11 | Right stick press |
| 3 | Top face button (Y / Triangle) | 12 | D-pad up |
| 4 | Left bumper | 13 | D-pad down |
| 5 | Right bumper | 14 | D-pad left |
| 6 | Left trigger | 15 | D-pad right |
| 7 | Right trigger | 16 | Center (Home / Guide) |
| 8 | Left center (Back / Select / Share) |
Axes 0 and 1 are the left stick (x, y) and 2 and 3 the right stick, with negative values meaning left and up. When mapping is "", the layout is whatever the driver reports, and it can differ between operating systems for the same controller, so offer a remapping screen and store the result per id. The "xr-standard" mapping is reserved for WebXR input sources and never appears in getGamepads().
A complete input layer¶
// Polls gamepads once per animation frame, applies a radial dead zone and reports
// button edges (pressed this frame) so game logic does not have to diff state.
const DEAD_ZONE = 0.15;
function stick(x, y) {
// Radial dead zone, rescaled so output still reaches 1.0 at full deflection.
const magnitude = Math.hypot(x, y);
if (magnitude < DEAD_ZONE) return { x: 0, y: 0 };
const scale = Math.min(1, (magnitude - DEAD_ZONE) / (1 - DEAD_ZONE)) / magnitude;
return { x: x * scale, y: y * scale };
}
export class GamepadInput extends EventTarget {
#previous = new Map(); // index -> { timestamp, pressed: boolean[] }
#frame = 0;
constructor() {
super();
window.addEventListener("gamepadconnected", ({ gamepad }) => {
this.dispatchEvent(new CustomEvent("connected", { detail: describe(gamepad) }));
this.#ensureLoop();
});
window.addEventListener("gamepaddisconnected", ({ gamepad }) => {
this.#previous.delete(gamepad.index);
this.dispatchEvent(new CustomEvent("disconnected", { detail: describe(gamepad) }));
});
}
#ensureLoop() {
if (!this.#frame) this.#frame = requestAnimationFrame(() => this.#poll());
}
#poll() {
this.#frame = 0;
let pads;
try {
pads = navigator.getGamepads();
} catch (error) {
// SecurityError: the gamepad Permissions Policy blocks this document.
this.dispatchEvent(new CustomEvent("error", { detail: error }));
return;
}
for (const pad of pads) {
if (!pad?.connected) continue;
const before = this.#previous.get(pad.index);
if (before?.timestamp === pad.timestamp) continue; // nothing changed
const pressed = pad.buttons.map((button) => button.pressed);
const justPressed = pressed
.map((isDown, i) => (isDown && !before?.pressed[i] ? i : -1))
.filter((i) => i >= 0);
this.#previous.set(pad.index, { timestamp: pad.timestamp, pressed });
this.dispatchEvent(
new CustomEvent("input", {
detail: {
index: pad.index,
standard: pad.mapping === "standard",
left: stick(pad.axes[0] ?? 0, pad.axes[1] ?? 0),
right: stick(pad.axes[2] ?? 0, pad.axes[3] ?? 0),
triggers: [pad.buttons[6]?.value ?? 0, pad.buttons[7]?.value ?? 0],
pressed,
justPressed,
},
}),
);
}
// requestAnimationFrame pauses in hidden tabs, which conveniently pauses polling too.
if (pads.some((pad) => pad?.connected)) this.#ensureLoop();
}
async rumble(index, { duration = 200, strong = 1, weak = 0.5, triggers = null } = {}) {
const actuator = navigator.getGamepads()[index]?.vibrationActuator;
if (!actuator) return "unsupported";
// effects is newer than playEffect(); assume dual-rumble when it is missing.
const effects = actuator.effects ?? ["dual-rumble"];
const type = triggers && effects.includes("trigger-rumble") ? "trigger-rumble" : "dual-rumble";
if (!effects.includes(type)) return "unsupported";
try {
return await actuator.playEffect(type, {
duration, // browsers may cap long effects; the spec recommends 5 s
startDelay: 0,
strongMagnitude: strong, // low-frequency motor, 0..1
weakMagnitude: weak, // high-frequency motor, 0..1
...(type === "trigger-rumble"
? { leftTrigger: triggers[0], rightTrigger: triggers[1] }
: {}),
}); // "complete" or "preempted" (a newer effect or reset() replaced it)
} catch (error) {
console.warn("Haptic effect rejected", error); // invalid parameters or disconnected pad
return "failed";
}
}
}
function describe(gamepad) {
return { index: gamepad.index, id: gamepad.id, standard: gamepad.mapping === "standard" };
}
Haptics support is uneven. vibrationActuator with "dual-rumble" shipped in Chrome 68 and Safari 16.4 on macOS; "trigger-rumble" and the effects list arrived in Chrome 126. Firefox exposes no vibrationActuator, and Safari on iOS does not support haptics through this API. Treat rumble as decoration.
Origin trial: event-driven gamepad input
Chrome is testing an event-driven model in which a rawgamepadinputchange event fires whenever new input arrives, instead of polling getGamepads() every frame. It is an origin trial (Chrome 149 to 154) and not available by default. Keep the polling loop as the baseline.
Gamepad support notes¶
The API works in Chrome 21 and later (unprefixed getGamepads() since 35), Firefox 29, Safari 10.1 on macOS and Safari on iOS 10.3 and later, on desktop and mobile. Installed PWAs behave exactly like tabs for controller input; the difference is presentation, since a fullscreen or standalone display mode gives a game the whole window without browser UI. In WebXR, controllers appear as XRInputSource.gamepad with the "xr-standard" mapping rather than through navigator.getGamepads().
Shape Detection: barcodes and QR codes¶
The Shape Detection API hands image analysis to the operating system's own detectors. Of its three interfaces only BarcodeDetector has shipped; FaceDetector and TextDetector remain behind flags in Chromium. Barcode detection is the one that matters for PWAs: QR-code onboarding and device pairing, ticket and boarding-pass scanning, inventory and warehouse apps, retail price checks and library checkouts, all without shipping a decoder.
It is not a device API in the chooser sense. There is no permission for detection itself: you supply the pixels, usually from a camera stream, and the camera has its own getUserMedia() permission.
BarcodeDetector in detail¶
const formats = await BarcodeDetector.getSupportedFormats(); // e.g. ["qr_code", "ean_13", ...]
const detector = new BarcodeDetector({ formats: ["qr_code", "ean_13"] });
const barcodes = await detector.detect(imageBitmapSource); // DetectedBarcode[]
- Formats:
aztec,code_128,code_39,code_93,codabar,data_matrix,ean_13,ean_8,itf,pdf417,qr_code,upc_a,upc_e, plusunknownfor results the platform cannot classify. Which ones work depends on the OS detector, so intersect your list withgetSupportedFormats(). An empty result means no usable detector on this platform. - Constructor: omitting
formatssearches for everything supported; limiting it is faster. An emptyformatsarray or one containing"unknown"throwsTypeError. detect(image)accepts anyImageBitmapSource:<img>,<video>,<canvas>,ImageBitmap,ImageData,OffscreenCanvas,VideoFrameor aBlob. It rejects withSecurityErrorfor cross-origin or tainted sources, and withInvalidStateErrorfor a broken image or a video whosereadyStateisHAVE_NOTHINGorHAVE_METADATA. A zero-sized source resolves with an empty array.- Results: each
DetectedBarcodehasrawValue(the decoded string, possibly multi-line),format,boundingBox(aDOMRectReadOnly) andcornerPoints(clockwise from top-left, not necessarily a rectangle because of perspective). - Cost: detectors can hold significant resources; create one and reuse it. The specification exposes the interface in workers as well as windows.
A complete camera scanner with a WebAssembly fallback¶
// Camera barcode scanner: native BarcodeDetector where the platform supports the
// formats, the ZXing-based "barcode-detector" ponyfill everywhere else.
async function createDetector(wanted) {
if ("BarcodeDetector" in globalThis) {
try {
const supported = await BarcodeDetector.getSupportedFormats();
const formats = wanted.filter((format) => supported.includes(format));
if (formats.length > 0) return new BarcodeDetector({ formats });
} catch {
// Fall through to the ponyfill.
}
}
// Loaded only when needed: the WebAssembly decoder is large.
const { BarcodeDetector: Ponyfill } = await import("barcode-detector/ponyfill");
return new Ponyfill({ formats: wanted });
}
export class BarcodeScanner extends EventTarget {
#video;
#stream = null;
#detector = null;
#running = false;
#last = { value: "", at: 0 };
constructor(video) {
super();
this.#video = video;
}
// Call from a click the first time: getUserMedia() shows the camera prompt.
async start({ formats = ["qr_code"] } = {}) {
this.#stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" }, width: { ideal: 1280 } },
audio: false,
});
this.#video.muted = true;
this.#video.playsInline = true; // iOS would otherwise switch to fullscreen playback
this.#video.srcObject = this.#stream;
await this.#video.play();
this.#detector = await createDetector(formats);
this.#running = true;
this.#scheduleNext();
}
#scheduleNext() {
if (!this.#running) return;
// Throttle to roughly 8 detections per second, aligned to decoded frames when possible.
setTimeout(() => {
if (typeof this.#video.requestVideoFrameCallback === "function") {
this.#video.requestVideoFrameCallback(() => this.#detect());
} else {
this.#detect();
}
}, 120);
}
async #detect() {
if (!this.#running) return;
try {
const barcodes = await this.#detector.detect(this.#video);
for (const { rawValue, format, cornerPoints } of barcodes) {
const now = performance.now();
// The same code stays in view for many frames: report it once per 2 seconds.
if (rawValue === this.#last.value && now - this.#last.at < 2000) continue;
this.#last = { value: rawValue, at: now };
this.dispatchEvent(new CustomEvent("detect", { detail: { rawValue, format, cornerPoints } }));
}
} catch (error) {
// InvalidStateError just means the video has no frame yet; anything else is real.
if (error.name !== "InvalidStateError") {
this.dispatchEvent(new CustomEvent("error", { detail: error }));
}
}
this.#scheduleNext(); // strictly sequential: never two detect() calls in flight
}
stop() {
this.#running = false;
for (const track of this.#stream?.getTracks() ?? []) track.stop(); // camera light off
this.#video.srcObject = null;
this.#stream = null;
}
}
The fallback uses the barcode-detector package, which implements the same interface on top of a ZXing WebAssembly build; its /ponyfill entry point exports the class without touching globalThis. Precache the WebAssembly file with your service worker if scanning must work offline.
Treat rawValue exactly like NFC payloads: it is untrusted text. Show a decoded URL to the user before navigating to it, and validate structured codes (tickets, pairing tokens) on the server.
Barcode Detection support¶
| Platform | Status |
|---|---|
| Chrome and Chromium browsers on Android | Supported since Chrome 83 |
| Chrome on macOS | Supported since Chrome 83; before Chrome 113 it silently failed on macOS 13 and later |
| Chrome on ChromeOS | Supported since Chrome 88 |
| Chrome on Windows and Linux | No usable detector; use the fallback |
| Safari 17+ (macOS, iOS, iPadOS) | Implemented behind the "Shape Detection API" feature flag, off by default |
| Firefox | Not implemented (Mozilla's position is "defer") |
Web MIDI¶
Web MIDI gives a page the system's MIDI inputs and outputs: USB-MIDI keyboards and pad controllers, synthesizers and drum machines behind a USB or DIN interface, virtual ports created by other applications, and optionally software synthesizers. Browser-based DAWs and sequencers, synth patch editors and librarians, piano-learning apps, lighting and show-control tools and firmware updaters for MIDI hardware are built on it.
Requesting access¶
const access = await navigator.requestMIDIAccess({ sysex: false, software: false });
MIDIOptions member | Default | Meaning |
|---|---|---|
sysex | false | Request System Exclusive messages. If the user or a policy refuses, the whole request rejects with NotAllowedError. Without it, sending SysEx throws and incoming SysEx is silently dropped. |
software | false | Include software synthesizers installed on the host. Refusal also rejects the request. |
The promise rejects with NotAllowedError (user refusal or the midi Permissions Policy), InvalidStateError (the OS MIDI stack failed), AbortError (the page is navigating away) or NotSupportedError. Each call may prompt again and returns a new MIDIAccess, so request once and share the object.
Permission behavior changed over time. Chrome originally prompted only for SysEx; from Chrome 124 (rolled out to all users by 125), all Web MIDI access requires permission. The permission descriptor is { name: "midi", sysex: true | false }, where the SysEx variant is stronger: a SysEx grant implies the plain one, not vice versa. Firefox (108 and later, desktop only) gates access behind the synthetically generated site-permission add-on described earlier on this page.
Ports, state and messages¶
access.inputs and access.outputs are map-like collections keyed by port id. Each MIDIPort has id, name, manufacturer, version, type ("input" or "output"), state ("connected" or "disconnected") and connection ("open", "closed" or "pending", the last meaning opened but currently unplugged). A statechange event (MIDIConnectionEvent with a port) fires on the MIDIAccess and on the port whenever a device appears, disappears, opens or closes.
The specification asks browsers to keep id stable across sessions and reboots, so persist the IDs of the ports the user selected and restore the selection on the next launch.
- Input: each
midimessageevent carriesdata, aUint8Arrayholding exactly one complete MIDI message, and atimeStampof when the system received it. Setting theonmidimessageattribute opens the port implicitly; withaddEventListener()callport.open()yourself. - Output:
output.send(data, timestamp)takes one or more complete messages. Running status is not allowed.timestampuses theperformance.now()clock, so you can schedule notes precisely ahead of time instead of relying onsetTimeout()jitter.send()throwsTypeErrorfor invalid data,NotAllowedErrorfor SysEx without SysEx access andInvalidStateErrorwhen the port is disconnected.output.clear(), which cancels queued messages, is implemented only in Firefox.
The specification exposes MIDI in workers, but Chromium has not implemented that; keep MIDI on the main thread for now.
A complete MIDI module¶
// Web MIDI: shared access, hot-plug tracking, message parsing, persisted port
// selection and sample-accurate scheduling via send() timestamps.
const accessCache = new Map(); // sysex flag -> Promise<MIDIAccess>
export function getMidiAccess({ sysex = false } = {}) {
if (!accessCache.has(sysex)) {
const request = navigator.requestMIDIAccess({ sysex }).catch((error) => {
accessCache.delete(sysex); // allow a retry after the user changes the setting
throw error;
});
accessCache.set(sysex, request);
}
return accessCache.get(sysex);
}
export function parseMessage(data) {
const status = data[0];
if (status === 0xf0) return { type: "sysex", bytes: data };
if (status >= 0xf8) return { type: "realtime", status }; // clock, start, stop, active sensing
const channel = (status & 0x0f) + 1;
switch (status & 0xf0) {
case 0x80:
return { type: "noteoff", channel, note: data[1], velocity: data[2] };
case 0x90: // note-on with velocity 0 is a note-off by MIDI convention
return data[2] === 0
? { type: "noteoff", channel, note: data[1], velocity: 0 }
: { type: "noteon", channel, note: data[1], velocity: data[2] };
case 0xa0:
return { type: "polypressure", channel, note: data[1], pressure: data[2] };
case 0xb0:
return { type: "cc", channel, controller: data[1], value: data[2] };
case 0xc0:
return { type: "program", channel, program: data[1] };
case 0xd0:
return { type: "channelpressure", channel, pressure: data[1] };
case 0xe0:
return { type: "pitchbend", channel, value: ((data[2] << 7) | data[1]) - 8192 };
default:
return { type: "system", status, bytes: data };
}
}
const STORAGE_KEY = "midi.outputId";
export class MidiRig extends EventTarget {
#access;
#output = null;
#attached = new Set(); // input port IDs that already have a listener
constructor(access) {
super();
this.#access = access;
access.addEventListener("statechange", ({ port }) => this.#onStateChange(port));
for (const input of access.inputs.values()) this.#attachInput(input);
this.selectOutput(localStorage.getItem(STORAGE_KEY)); // restore last choice
}
get outputs() {
return [...this.#access.outputs.values()].map(({ id, name, manufacturer }) => ({
id,
name,
manufacturer,
}));
}
selectOutput(id) {
this.#output = (id && this.#access.outputs.get(id)) || null;
if (this.#output) localStorage.setItem(STORAGE_KEY, id);
return this.#output !== null;
}
#attachInput(input) {
// statechange fires several times per port (connect, open, close): add one listener only.
if (!this.#attached.has(input.id)) {
this.#attached.add(input.id);
input.addEventListener("midimessage", ({ data, timeStamp }) => {
this.dispatchEvent(
new CustomEvent("message", {
detail: { port: input.id, timeStamp, ...parseMessage(data) },
}),
);
});
}
// addEventListener() does not open the port implicitly (only onmidimessage does),
// and a re-plugged device comes back closed.
if (input.connection !== "open") {
input.open().catch((error) => console.warn(`Cannot open ${input.name}`, error));
}
}
#onStateChange(port) {
if (port.type === "input" && port.state === "connected") {
this.#attachInput(port); // new or re-plugged device
}
if (port === this.#output && port.state === "disconnected") {
this.dispatchEvent(new Event("outputlost")); // keep the ID; it may come back
}
this.dispatchEvent(new CustomEvent("ports", { detail: port }));
}
// Schedules a chord with precise timing: offsetMs from now, lasting durationMs.
playChord(notes, { channel = 1, velocity = 100, offsetMs = 0, durationMs = 500 } = {}) {
if (!this.#output) throw new Error("No MIDI output selected");
const start = performance.now() + offsetMs;
const ch = (channel - 1) & 0x0f;
for (const note of notes) {
this.#output.send([0x90 | ch, note & 0x7f, velocity & 0x7f], start);
this.#output.send([0x80 | ch, note & 0x7f, 0], start + durationMs);
}
}
// "Panic": All Notes Off (CC 123) on every channel, e.g. on pagehide.
panic() {
if (this.#output?.state !== "connected") return;
for (let ch = 0; ch < 16; ch++) this.#output.send([0xb0 | ch, 123, 0]);
}
sendSysex(bytes) {
if (!this.#access.sysexEnabled) throw new DOMException("SysEx not granted", "NotAllowedError");
if (bytes[0] !== 0xf0 || bytes.at(-1) !== 0xf7) throw new TypeError("Malformed SysEx");
this.#output?.send(bytes);
}
}
Always call panic() from a pagehide listener: notes that were switched on but never off keep sounding on hardware synthesizers after the tab closes.
Why SysEx is gated separately¶
System Exclusive messages are vendor-defined and can rewrite a device's settings, patches and on many devices its firmware. That is why SysEx access has always required explicit consent, why the SysEx permission is "stronger" than plain MIDI access, and why Firefox wraps the whole API in an add-on install flow. Request sysex: true only in the part of your app that needs it, for example a firmware-update screen, and keep ordinary note input on the plain permission.
Web MIDI support¶
Chrome, Edge and Opera on desktop and Chrome for Android support Web MIDI (since Chrome 43). Firefox 108 and later supports it on desktop only, behind the site-permission add-on. Safari does not implement it, and WebKit lists Web MIDI among the APIs it has decided not to implement, so on iOS no browser can reach MIDI devices.
WebXR¶
The WebXR Device API renders virtual and augmented reality: stereo views and head tracking on a headset, or camera-backed AR on a phone. It is a rendering API as much as a device API, and a full treatment (WebGL/WebGPU layers, input sources, hit testing, anchors) is beyond this page. What matters for a PWA is how a session is requested and where it runs.
Session modes, features and reference spaces¶
- Detection:
navigator.xrexists only in secure contexts on supporting browsers, andawait navigator.xr.isSessionSupported(mode)tells you whether a device can run a mode. - Modes:
"inline"(rendered in the page, always allowed),"immersive-vr"(exclusive headset display) and"immersive-ar"(defined by the WebXR AR Module). - Features:
requestSession(mode, { requiredFeatures, optionalFeatures }). A required feature that is unknown, unsupported or not consented blocks the session; optional features are enabled when possible. Reference space types are valid feature names:"viewer","local","local-floor","bounded-floor"and"unbounded". - Errors:
SecurityErrorwhen an immersive request lacks transient activation or thexr-spatial-trackingpolicy blocks it;InvalidStateErrorwhen another immersive session is pending or active (only one at a time);NotSupportedErrorwhen no device supports the mode, a required feature is unavailable, or the user declines.
Immersive requests need user activation. The specification also allows a request "when launching a web application", but a button remains the only portable trigger.
Entering VR from a button¶
// Minimal immersive-vr entry: detection, a user-activated request and the frame loop.
const button = document.querySelector("#enter-vr");
if (navigator.xr && (await navigator.xr.isSessionSupported("immersive-vr"))) {
button.hidden = false;
button.addEventListener("click", enterVR); // requestSession() is the first await
}
async function enterVR() {
let session;
try {
session = await navigator.xr.requestSession("immersive-vr", {
requiredFeatures: ["local-floor"],
optionalFeatures: ["bounded-floor", "hand-tracking"],
});
} catch (error) {
// SecurityError, InvalidStateError or NotSupportedError: see the list above.
showMessage(`VR unavailable: ${error.name}`);
return;
}
// The WebGL context must be XR-compatible before creating the layer.
const gl = document.createElement("canvas").getContext("webgl2", { xrCompatible: true });
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
const space = await session.requestReferenceSpace("local-floor");
button.disabled = true;
session.addEventListener("end", () => (button.disabled = false)); // user took the headset off
session.requestAnimationFrame(function onFrame(time, frame) {
session.requestAnimationFrame(onFrame);
const pose = frame.getViewerPose(space);
if (!pose) return; // tracking lost: skip the frame
const layer = session.renderState.baseLayer;
gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
for (const view of pose.views) {
const { x, y, width, height } = layer.getViewport(view);
gl.viewport(x, y, width, height);
drawScene(gl, view.projectionMatrix, view.transform.inverse.matrix); // your renderer
}
});
}
Note that window.requestAnimationFrame() does not drive an immersive session; only session.requestAnimationFrame() does, at the headset's refresh rate.
Where WebXR runs¶
WebXR shipped in Chrome 79 on desktop (with a PC-connected headset) and Android, with immersive-ar on ARCore-capable Android devices since Chrome 81. The Meta Quest browser and Samsung Internet support it, and Safari 18 added immersive WebXR on visionOS 2. Firefox has no WebXR implementation despite Mozilla's positive standards position, and Safari on iOS, iPadOS and macOS does not expose it. For iPhone users, AR Quick Look (an <a rel="ar"> link to a USDZ file) remains the practical alternative.
Browser support¶
| API | Chrome / Edge desktop | Chrome Android | Firefox | Safari macOS | Safari iOS/iPadOS |
|---|---|---|---|---|---|
| Web Bluetooth | ⚠️ 56 / 70 | ✅ 56 | ❌ | ❌ | ❌ |
| WebUSB | ✅ 61 | ✅ 61 | ❌ | ❌ | ❌ |
| WebHID | ✅ 89 | ❌ | ❌ | ❌ | ❌ |
| Web Serial | ✅ 89 | ⚠️ 138 / ✅ 148 | ⚠️ 151 | ❌ | ❌ |
Web NFC (NDEFReader) | ❌ | ✅ 89 | ❌ | ❌ | ❌ |
NDEFReader.makeReadOnly() | ❌ | ✅ 100 | ❌ | ❌ | ❌ |
| Geolocation API | ✅ 5 | ✅ 18 | ✅ 3.5 | ✅ 5 | ✅ |
GeolocationPosition.toJSON() | ✅ 126 | ✅ 126 | ✅ 129 | ✅ 18 | ✅ 18 |
<geolocation> element | ✅ 144 | ✅ 144 | ❌ | ❌ | ❌ |
Accelerometer, Gyroscope, LinearAccelerationSensor, orientation sensors | ✅ 67 | ✅ 67 | ❌ | ❌ | ❌ |
GravitySensor | ✅ 91 | ✅ 91 | ❌ | ❌ | ❌ |
Magnetometer, AmbientLightSensor | 🧪 | 🧪 | ❌ | ❌ | ❌ |
deviceorientation event | ✅ 7 | ✅ 18 | ✅ 6 | ⚠️ 17 | ✅ 4.2 |
devicemotion event | ✅ 31 | ✅ 31 | ✅ 6 | ⚠️ 17 | ✅ 4.2 |
deviceorientationabsolute event | ✅ 50 | ✅ 50 | ✅ 110 | ❌ | ❌ |
DeviceOrientationEvent / DeviceMotionEvent .requestPermission() | ⚠️ 151 | ⚠️ 151 | ❌ | ❌ | ✅ 14.5 |
| Gamepad API | ✅ 21 | ✅ 25 | ✅ 29 | ✅ 10.1 | ✅ 10.3 |
Gamepad vibrationActuator ("dual-rumble") | ✅ 68 | ✅ 68 | ❌ | ✅ 16.4 | ❌ |
BarcodeDetector | ⚠️ 83 | ✅ 83 | ❌ | 🧪 17 | 🧪 17 |
| Web MIDI | ✅ 43 | ✅ 43 | ⚠️ 108 | ❌ | ❌ |
| WebXR Device API | ✅ 79 | ✅ 79 | ❌ | ❌ | ❌ |
Support data as of September 2026. Versions come from MDN's browser compatibility data (shown on each API's MDN page linked under Further reading), Chrome release notes and chromestatus.com; check caniuse for live data: Web Bluetooth, WebUSB, WebHID, Web Serial, Web NFC, Geolocation, Accelerometer, DeviceOrientation, Gamepad, Web MIDI and WebXR.
Notes on the ⚠️ and 🧪 entries:
- Web Bluetooth, desktop: macOS since Chrome 56, Windows 10 and later since Chrome 70, ChromeOS supported; Linux is not enabled by default.
- Web Serial, Android: Chrome 138 exposed only Bluetooth RFCOMM serial ports; wired ports followed in Chrome 148. Firefox 151 supports Web Serial on desktop only, and both Web Serial and Web MIDI (Firefox 108) are gated by a site-permission add-on; Firefox for Android supports neither.
requestPermission()in Chromium: listed in the Chrome 151 release notes (MDN records 152). While Chrome's "Motion sensors" default remains Allow, the call usually resolves"granted"without a prompt.- Orientation and motion events on Safari macOS: the interfaces exist, but Macs have no motion sensors, so no events arrive.
BarcodeDetector, desktop: works on macOS (Chrome 83) and ChromeOS (Chrome 88) only. In Safari 17 and later it sits behind the "Shape Detection API" feature flag.- Generic Sensor extras:
MagnetometerandAmbientLightSensorrequire#enable-generic-sensor-extra-classes. - WebXR also runs in the Meta Quest browser, Samsung Internet and Safari on visionOS 2 (Safari 18), none of which fit the columns above.
- The Safari iOS/iPadOS column applies to every browser on iOS and iPadOS, because all of them use WebKit. Edge, Opera and Samsung Internet follow their Chromium base version for most of these APIs.
Common pitfalls¶
- Awaiting before the chooser or prompt.
requestDevice(),requestPort(),requestPermission(), NFC's firstscan()and immersiverequestSession()all need transient activation. Any slowawaitbefore them (a fetch, a dynamic import) can expire it and turns the call intoSecurityErrororNotAllowedError. Make the device call the firstawaitin the click handler. - Reporting a canceled chooser as a failure. Bluetooth, USB and Serial reject with
NotFoundErrorwhen the user closes the chooser, and WebHID resolves with[]. Neither is a bug to log. - Equating "interface exists" with "feature works".
BarcodeDetectorcan exist with no supported formats,navigator.bluetoothcan exist with no adapter, sensor classes exist on desktops without sensors, and orientation events can exist and never fire. Probe the deeper signal (getSupportedFormats(),getAvailability(),errorevents, a watchdog timer). - Asking for location on page load. Three dismissals put your origin under a seven-day automatic block in Chromium, and a denial is permanent until the user edits site settings. Ask from a button, or use
<geolocation>where supported. - Expecting anything to run in the background. Geolocation updates are dropped while the page is hidden, NFC is suspended, sensor readings stop and
requestAnimationFrame()(and with it gamepad polling) pauses. No device API works from a service worker. - Mixing units and frames.
devicemotionreports rotation in deg/s,Gyroscopein rad/s; GATT values are little-endian whileDataViewdefaults to big-endian; orientation angles are in the device frame, not the screen frame. UsereferenceFrame: "screen"or compensate withscreen.orientation.angle. - Holding on to stale objects. Chromium's
Gamepadobjects are snapshots, GATT characteristic objects die with the connection, andnavigator.bluetooth.getDevices()is still flagged, so a reload loses theBluetoothDevice. Re-read and re-acquire. - Skipping cleanup. Clear geolocation watches, stop camera tracks after scanning, send MIDI All Notes Off on
pagehide, follow the serial close sequence (cancel readers, release locks, thenclose()), and callforget()when the user disconnects a device for good. - Trusting device data. NFC tags and barcodes can be rewritten or printed by anyone, tag serial numbers can be cloned, and serial or HID devices can send malformed frames. Validate and sanitize everything, and never auto-navigate to a URL read from the physical world.
- Embedding device features in cross-origin iframes without delegation. Each API needs its Permissions Policy token in the iframe's
allowattribute, and Web NFC does not work in iframes at all. - Testing only on desktop Chrome. Web NFC is Android-only, WebHID is desktop-only, Web Serial on Android is new, and iOS exposes none of the Chromium-only APIs. Keep a device matrix and test the fallbacks as seriously as the happy path.
- Calling
requestMIDIAccess()repeatedly. Each call can prompt again and returns a new object; request once, then share theMIDIAccess.
Debugging¶
- Remote-debug Android devices through
chrome://inspectwith USB debugging enabled; NFC, motion sensors and Bluetooth on Android can only be tested on hardware. - Chrome's internal pages show what JavaScript errors hide:
about://device-log(driver claims, permission failures, Bluetooth and USB errors),about://bluetooth-internals(adapters, discovered devices, GATT services) andabout://usb-internals(descriptors and test devices). - DevTools sensor emulation: the Sensors panel overrides geolocation (including error states) and device orientation, which exercises your UI without hardware. See Browser DevTools.
- Permission state: reset a site's permissions from the site information icon in the address bar, and log
navigator.permissions.query()results for"geolocation","midi","nfc","accelerometer"and"gyroscope"alongside bug reports. - Silent APIs: for orientation events and sensors, log whether any event arrived within a second of starting. Silence usually means permission or policy, not hardware.
Further reading¶
On this site
- Device & OS Integration: the capability overview this page belongs to
- Permissions: how browser permission prompts, states and policies work
- Privacy & Storage Partitioning: the privacy model behind permission gating
- Media & System APIs: Screen Wake Lock, Screen Orientation, Vibration and more
- iOS & iPadOS: which of these APIs exist in WebKit and in Home Screen web apps
- Android: installed PWAs and platform behavior on Android
- Desktop Platforms: installed PWAs on Windows, macOS, Linux and ChromeOS
- Isolated Web Apps:
usb-unrestrictedand other capabilities beyond ordinary PWAs
External references
- Specifications: Web Bluetooth, WebUSB, WebHID, Web Serial, Web NFC, Geolocation, Generic Sensor, Device Orientation and Motion, Gamepad, Shape Detection, Web MIDI and WebXR Device API
- MDN: Web Bluetooth API, WebUSB API, WebHID API, Web Serial API, Web NFC API, Geolocation API,
<geolocation>, Sensor APIs, Device orientation events,DeviceOrientationEvent.requestPermission(), Gamepad API, Barcode Detection API, Web MIDI API, WebXR Device API - Chrome for Developers: Web Bluetooth, WebUSB, WebHID, Web Serial, Web NFC, Generic Sensor API
- Release notes: Chrome 124 (Web MIDI permission prompt), Chrome 144 (
<geolocation>), Chrome 151 (orientationrequestPermission()), Safari 18 (immersive WebXR on visionOS) - ChromeStatus: DeviceOrientation events permission request API, The
<geolocation>element, Approximate geolocation, Gamepad event-driven input - Capability elements explainer (WICG/PEPC), the Web NFC blocklist and the barcode-detector polyfill
- Standards positions: Mozilla and WebKit; Firefox's site permission add-ons