JavaScript Web Bluetooth API Table

PieceWhat it doesField note
navigator.bluetoothThe feature probeundefined on Safari/Firefox/iOS; getAvailability() is a hint, not a guarantee - detect first
requestDevice({filters})The user gatebrowser chooser inside a user gesture; filters are least privilege, the grant is session-scoped
optionalServicesThe scope grantgetPrimaryService rejects undeclared UUIDs - the security boundary behind the chooser
gatt.connect()The session openerreturns a server promise; reconnect the SAME device object - never re-requestDevice
getCharacteristic(uuid)The data point16-bit standard UUIDs (0x180F battery) vs 128-bit vendor; readValue gives a DataView
startNotificationsThe push modelcharacteristicvaluechanged listener - subscribe once, let the device push, skip polling
writeValueWith/WithoutResponseThe write flavorsacked for commands, unacked for streams; queue writes yourself, plan on ~20-byte legacy MTU
gattserverdisconnectedThe steady statenot an error - devices sleep and wander; show a state chip, reconnect via gatt.connect()
Reference: the MDN Web Bluetooth API. A Chromium-only capability with a strict etiquette: gesture-gated chooser, least-privilege filters, session-scoped grants - most broken integrations break the etiquette, not the GATT protocol.
Bottom line: the disconnect is the design assumption. gattserverdisconnected is the steady state of a radio link, not a failure - keep the device object, reconnect with gatt.connect() on the SAME device, and show connection state in the UI. Subscribe to notifications instead of polling, pick the write flavor by whether a dropped packet matters, and remember iOS and Firefox ship nothing here: feature-detect and say what the page does without it.
Related tools: the Web Serial table (the wired sibling), the Web MIDI table (the music sibling), the WebAuthn table (Bluetooth security keys as a transport), the permissions table (the grant model in general), and the gamepad table (input hardware without radios).

The Web Bluetooth API lets a page talk to low-energy devices directly - heart-rate belts, micro:bits, LED strips, bike sensors - through the GATT profile every BLE device speaks: a tree of services, each holding characteristics, each readable, writable or notifiable. The whole flow is promise-chained: navigator.bluetooth.requestDevice({filters}) shows the browser's device chooser, device.gatt.connect() opens the session, and getPrimaryService().getCharacteristic() walks down to the data point you actually want.

Bottom line: Web Bluetooth is a Chromium-only capability with a strict etiquette, and most broken integrations break the etiquette, not the protocol. requestDevice must run inside a user gesture (the chooser is browser UI, not yours); filters are least-privilege - a page can only ever touch services it declared in optionalServices; and the chooser grant is per-device, per-origin and session-scoped, so design a re-pair flow instead of hoping the grant survives a reload.

The honest part is the disconnect: gattserverdisconnected is not an error state, it is the steady state - BLE devices sleep, wander out of range, and reboot by design. The professional pattern keeps the device object and re-calls gatt.connect() on the SAME device (never re-requestDevice - the chooser would come back), and treats notifications as the only efficient read model: subscribe once with startNotifications and let the device push, instead of polling readValue and burning the connection budget. iOS Safari and Firefox ship nothing here - feature-detect navigator.bluetooth and say what the page does without it.

How to use

  1. Probe and request: const d = await navigator.bluetooth.requestDevice({filters:[{services:[0x180d]}]}) - the heart-rate service UUID picks only heart-rate devices, and the gesture requirement means this line belongs in a click handler. getAvailability() is a hint, not a guarantee - a true return can still fail at chooser time.
  2. Climb the GATT ladder: const server = await d.gatt.connect(); const svc = await server.getPrimaryService(0x180d); const ch = await svc.getCharacteristic(0x2a37); - standard 16-bit UUIDs (0x180F battery, 0x180D heart rate) or full 128-bit vendor UUIDs; every service you touch must appear in optionalServices or getPrimaryService rejects.
  3. Read and write: const v = await ch.readValue() returns a DataView (parse little-endian: v.getUint8(0)); writes come in two flavors - writeValueWithResponse for commands where order matters, writeValueWithoutResponse for high-frequency streams like LED frames, and queue them yourself because the stack silently drops on overflow.
  4. Subscribe instead of poll: await ch.startNotifications(); ch.addEventListener('characteristicvaluechanged', e => parse(e.target.value)) - the device pushes on its own cadence (battery, HRM, sensors), which is the whole point of BLE. Handle stopNotifications on teardown.
  5. Survive disconnects: listen for gattserverdisconnected on the device, show state in the UI, and reconnect with d.gatt.connect() - same device object, no chooser. Do NOT call requestDevice again on reconnect: the grant you already have is the point of the session model.

Frequently asked questions

Why does requestDevice fail or show nothing in my browser?

Three gates stack up. Gesture: it must run inside a real user interaction - a promise chain that loses the user-activation context (a setTimeout hop, an await that takes too long) gets rejected with SecurityError. Platform: Web Bluetooth ships in Chromium only - Chrome, Edge, Opera on desktop and Android; Safari (including iOS) and Firefox have nothing, so navigator.bluetooth is undefined there and feature detection is step zero. OS: the machine needs Bluetooth powered on and, on Linux, BlueZ 5.43+ - and note the web chooser talks to devices directly without OS pairing, so a device 'paired' in system settings is irrelevant (sometimes even a nuisance for reconnection). Finally the chooser lists only devices matching your filters - an over-tight services filter that names a UUID the device does not expose will hide it completely.

What exactly do the filters and optionalServices control?

Least privilege, twice. The filters array (services, name, namePrefix) decides which devices appear in the chooser - requestDevice({acceptAllDevices:true}) shows everything nearby but then locks you out of every service except those listed in optionalServices; the stricter and cleaner pattern is filtering by the service UUID you actually need. optionalServices is the security boundary: getPrimaryService rejects any UUID not declared there, so a compromised or careless page cannot go spelunking through a device's other profiles. Custom 128-bit UUIDs work the same way - put the vendor's full UUID string in both the filter and optionalServices. The mental model: the chooser grants a device, optionalServices grants a scope, and neither persists - which is why your code should treat every session as a fresh pairing.

Why do my writes silently fail or arrive out of order?

BLE writes are fire-and-forget at the link layer when you pick the wrong flavor, and the stack buffers more than it delivers. writeValueWithoutResponse is fast but unacknowledged - right for LED frames or motor ticks where the next packet supersedes the last; wrong for commands like 'unlock' where a dropped packet is a bug report. writeValueWithResponse (or the older writeValue) waits for the peripheral's ack - slower, ordered, and the correct default until profiling says otherwise. Queue on top: serialize writes through a promise chain rather than firing in a loop, because characteristic-level concurrency interleaves and the GATT transaction timeout (30s) punishes bursts. And keep packets under the MTU - a 20-byte default payload is still the honest planning number for older peripherals.

Does the permission survive reloads, and how do I reconnect cleanly?

No - Web Bluetooth grants are session-scoped by design: the chooser choice lives until the tab closes, and there is no persisted-permission API for it (unlike camera or notifications). So the architecture is: keep the BluetoothDevice object for the whole session, listen for gattserverdisconnected, and on reconnect call device.gatt.connect() again - the SAME device object reconnects without UI. Calling requestDevice again forces the chooser back on the user and mints a new device object; only do that when the old object is truly gone (page reload). For long-lived dashboards, add a visible device state chip (connected / reconnecting / choose device) - users forgive BLE dropouts when the page shows it is handling them, and never forgive a chooser popping up unrequested.

Related tools