Introduction
The browser has evolved from a document viewer into a full application platform, but for years it remained isolated from the physical world of hardware devices. Web Bluetooth and Web USB change that fundamentally, enabling web applications to communicate directly with Bluetooth Low Energy devices and USB peripherals. This opens possibilities that were previously exclusive to native applications: configuring IoT sensors, reading fitness tracker data, controlling robotics, and communicating with specialized hardware like 3D printers and scientific instruments.
The Web Bluetooth API, shipping in Chrome since 2016, provides access to Bluetooth Low Energy (BLE) devices through a standards-based interface. The Web USB API, also available in Chrome, allows direct communication with USB devices that don't have standard OS drivers. Together, these APIs represent the browser's expansion into the physical computing space, eliminating the need for native companion apps or platform-specific drivers for many hardware interaction scenarios.
This guide explores both APIs in depth, covering their architectures, real-world implementation patterns, and the critical considerations for building reliable hardware-connected web applications. Whether you're building an IoT dashboard, a medical device interface, or a hardware debugging tool, understanding these APIs is essential for modern web development.
Understanding Web Bluetooth API
How Bluetooth Low Energy Works
Bluetooth Low Energy (BLE) operates on a client-server model organized around GATT (Generic Attribute Profile). A BLE device (peripheral) exposes a set of services, each containing one or more characteristics. Characteristics are the data endpoints—they can be read, written, or subscribed to for notifications.
The hierarchy is:
- Service: A collection of related data (e.g., Heart Rate Service, Battery Service)
- Characteristic: A specific data point within a service (e.g., Heart Rate Measurement)
- Descriptor: Metadata about a characteristic (e.g., user description, format)
Each service and characteristic is identified by a UUID. Standard services use 16-bit UUIDs defined by the Bluetooth SIG (e.g., 0x180D for Heart Rate), while custom services use 128-bit UUIDs.
The Web Bluetooth API Flow
// Step 1: Request device with specific filters
const device = await navigator.bluetooth.requestDevice({
filters: [
{ services: ['heart_rate'] },
{ namePrefix: 'MyDevice' },
{ manufacturerData: [{ companyIdentifier: 0x004c }] },
],
optionalServices: ['battery_service'],
});
// Step 2: Connect to GATT server
const server = await device.gatt.connect();
// Step 3: Get service
const service = await server.getPrimaryService('heart_rate');
// Step 4: Get characteristic
const characteristic = await service.getCharacteristic('heart_rate_measurement');
// Step 5: Read or subscribe
const value = await characteristic.readValue();
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', (event) => {
const heartRate = event.target.value.getUint8(1);
console.log(`Heart rate: ${heartRate}`);
});Device Filtering
Web Bluetooth requires explicit filtering criteria to show only relevant devices in the chooser dialog:
// Filter by service UUID
const options1 = {
filters: [{ services: [0x180F] }], // Battery Service
};
// Filter by device name
const options2 = {
filters: [{ name: 'My Sensor' }],
};
// Filter by name prefix
const options3 = {
filters: [{ namePrefix: 'Arduino' }],
};
// Accept all devices with optional service discovery
const options4 = {
acceptAllDevices: true,
optionalServices: ['battery_service', 'device_information'],
};Understanding Web USB API
How Web USB Works
USB devices communicate through endpoints organized into interfaces and configurations. Unlike Bluetooth's service/characteristic model, USB uses a more direct pipe-based communication model. Each endpoint has a direction (IN or OUT) and a transfer type (control, bulk, interrupt, isochronous).
The Web USB API provides access to USB devices that don't have a standard kernel driver attached. This means devices that would normally require a custom driver or companion app can be accessed directly from the browser.
The Web USB API Flow
// Step 1: Request device
const device = await navigator.usb.requestDevice({
filters: [
{ vendorId: 0x2341, productId: 0x0043 }, // Arduino Uno
{ classCode: 0xff, subclassCode: 0x01 }, // Vendor-specific class
],
});
// Step 2: Open the device
await device.open();
// Step 3: Select configuration
await device.selectConfiguration(1);
// Step 4: Claim interface
await device.claimInterface(0);
// Step 5: Transfer data
const result = await device.transferIn(1, 64); // endpoint 1, 64 bytes
console.log(new Uint8Array(result.data.buffer));
// Step 6: Send data
const data = new Uint8Array([0x01, 0x02, 0x03]);
await device.transferOut(1, data);USB Device Classes
| Class | Code | Examples |
|---|---|---|
| Audio | 0x01 | Speakers, microphones |
| Communications | 0x02 | Modems, serial adapters |
| HID | 0x03 | Keyboards, mice, gamepads |
| Mass Storage | 0x08 | Flash drives, external HDDs |
| Video | 0x0E | Webcams, capture cards |
| Vendor Specific | 0xFF | Custom hardware, IoT devices |
Web USB primarily targets vendor-specific and custom devices, as standard device classes are typically handled by the OS.
Architecture and Design Patterns
The Hardware Abstraction Layer
Create an abstraction layer that normalizes the differences between Bluetooth and USB communication:
interface HardwareDevice {
id: string;
name: string;
type: 'bluetooth' | 'usb';
connected: boolean;
connect(): Promise<void>;
disconnect(): Promise<void>;
read(endpoint: string): Promise<DataView>;
write(endpoint: string, data: ArrayBuffer): Promise<void>;
subscribe(endpoint: string, callback: (data: DataView) => void): () => void;
}
class BLEDevice implements HardwareDevice {
id: string;
name: string;
type = 'bluetooth' as const;
connected = false;
private device: BluetoothDevice | null = null;
private server: BluetoothRemoteGATTServer | null = null;
private services: Map<string, BluetoothRemoteGATTService> = new Map();
private listeners: Map<string, Set<(data: DataView) => void>> = new Map();
constructor(device: BluetoothDevice) {
this.id = device.id;
this.name = device.name || 'Unknown BLE Device';
this.device = device;
device.addEventListener('gattserverdisconnected', () => {
this.connected = false;
this.services.clear();
});
}
async connect() {
this.server = await this.device!.gatt!.connect();
this.connected = true;
}
async disconnect() {
this.server?.disconnect();
this.connected = false;
this.services.clear();
}
private async getService(uuid: string): Promise<BluetoothRemoteGATTService> {
if (this.services.has(uuid)) return this.services.get(uuid)!;
const service = await this.server!.getPrimaryService(uuid);
this.services.set(uuid, service);
return service;
}
async read(serviceUUID: string, characteristicUUID: string): Promise<DataView> {
const service = await this.getService(serviceUUID);
const characteristic = await service.getCharacteristic(characteristicUUID);
return characteristic.readValue();
}
async write(serviceUUID: string, characteristicUUID: string, data: ArrayBuffer): Promise<void> {
const service = await this.getService(serviceUUID);
const characteristic = await service.getCharacteristic(characteristicUUID);
await characteristic.writeValue(data);
}
subscribe(serviceUUID: string, characteristicUUID: string, callback: (data: DataView) => void): () => void {
const key = `${serviceUUID}:${characteristicUUID}`;
if (!this.listeners.has(key)) this.listeners.set(key, new Set());
this.listeners.get(key)!.add(callback);
this._startNotifications(serviceUUID, characteristicUUID);
return () => {
this.listeners.get(key)?.delete(callback);
if (this.listeners.get(key)?.size === 0) {
this._stopNotifications(serviceUUID, characteristicUUID);
}
};
}
private async _startNotifications(serviceUUID: string, characteristicUUID: string) {
const service = await this.getService(serviceUUID);
const characteristic = await service.getCharacteristic(characteristicUUID);
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', (event: any) => {
const key = `${serviceUUID}:${characteristicUUID}`;
this.listeners.get(key)?.forEach(cb => cb(event.target.value));
});
}
private async _stopNotifications(serviceUUID: string, characteristicUUID: string) {
const service = await this.getService(serviceUUID);
const characteristic = await service.getCharacteristic(characteristicUUID);
await characteristic.stopNotifications();
}
}Step-by-Step Implementation
Building a Bluetooth Heart Rate Monitor
import { useState, useCallback, useEffect, useRef } from 'react';
interface HeartRateData {
bpm: number;
timestamp: number;
rrIntervals?: number[];
}
export function useHeartRateMonitor() {
const [device, setDevice] = useState<BluetoothDevice | null>(null);
const [heartRate, setHeartRate] = useState<HeartRateData | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const characteristicRef = useRef<BluetoothRemoteGATTCharacteristic | null>(null);
const connect = useCallback(async () => {
try {
setError(null);
const btDevice = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }],
optionalServices: ['battery_service'],
});
btDevice.addEventListener('gattserverdisconnected', () => {
setConnected(false);
});
const server = await btDevice.gatt!.connect();
const service = await server.getPrimaryService('heart_rate');
const characteristic = await service.getCharacteristic('heart_rate_measurement');
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', (event: any) => {
const value = event.target.value;
const flags = value.getUint8(0);
const is16Bit = flags & 0x01;
const hasRRIntervals = flags & 0x10;
let bpm: number;
let offset = 1;
if (is16Bit) {
bpm = value.getUint16(offset, true);
offset += 2;
} else {
bpm = value.getUint8(offset);
offset += 1;
}
const rrIntervals: number[] = [];
if (hasRRIntervals) {
while (offset < value.byteLength) {
rrIntervals.push(value.getUint16(offset, true) / 1024 * 1000);
offset += 2;
}
}
setHeartRate({
bpm,
timestamp: Date.now(),
rrIntervals: rrIntervals.length > 0 ? rrIntervals : undefined,
});
});
characteristicRef.current = characteristic;
setDevice(btDevice);
setConnected(true);
} catch (err: any) {
setError(err.message);
}
}, []);
const disconnect = useCallback(() => {
characteristicRef.current?.stopNotifications();
device?.gatt?.disconnect();
setConnected(false);
setDevice(null);
}, [device]);
useEffect(() => {
return () => { disconnect(); };
}, [disconnect]);
return { heartRate, connected, error, connect, disconnect };
}Building a USB Serial Communication Interface
class USBSerialConnection {
private device: USBDevice | null = null;
private endpointIn: USBEndpoint | null = null;
private endpointOut: USBEndpoint | null = null;
private readLoop: boolean = false;
async connect(filters: USBDeviceFilter[]) {
this.device = await navigator.usb.requestDevice({ filters });
await this.device.open();
if (this.device.configuration === null) {
await this.device.selectConfiguration(1);
}
const iface = this.device.configuration!.interfaces[0];
await this.device.claimInterface(iface.interfaceNumber);
const endpoints = iface.alternate.endpoints;
this.endpointIn = endpoints.find(e => e.direction === 'in') || null;
this.endpointOut = endpoints.find(e => e.direction === 'out') || null;
if (!this.endpointIn || !this.endpointOut) {
throw new Error('Device missing required endpoints');
}
return {
productName: this.device.productName,
serialNumber: this.device.serialNumber,
};
}
async disconnect() {
this.readLoop = false;
if (this.device?.opened) {
await this.device.releaseInterface(0);
await this.device.close();
}
this.device = null;
}
async send(data: Uint8Array) {
if (!this.device || !this.endpointOut) throw new Error('Not connected');
await this.device.transferOut(this.endpointOut.endpointNumber, data);
}
async receive(): Promise<Uint8Array> {
if (!this.device || !this.endpointIn) throw new Error('Not connected');
const result = await this.device.transferIn(
this.endpointIn.endpointNumber,
this.endpointIn.packetSize
);
if (result.status === 'ok' && result.data) {
return new Uint8Array(result.data.buffer);
}
throw new Error(`Transfer failed: ${result.status}`);
}
startReading(callback: (data: Uint8Array) => void): () => void {
this.readLoop = true;
const loop = async () => {
while (this.readLoop && this.device?.opened) {
try {
const data = await this.receive();
callback(data);
} catch (err) {
if (this.readLoop) console.error('Read error:', err);
break;
}
}
};
loop();
return () => { this.readLoop = false; };
}
}React Component for USB Serial Terminal
function USBSerialTerminal() {
const [connection] = useState(() => new USBSerialConnection());
const [connected, setConnected] = useState(false);
const [log, setLog] = useState<string[]>([]);
const [input, setInput] = useState('');
const connect = async () => {
try {
const info = await connection.connect([
{ vendorId: 0x2341 },
{ classCode: 0xff },
]);
setConnected(true);
addLog(`Connected to ${info.productName}`);
connection.startReading((data) => {
const text = new TextDecoder().decode(data);
addLog(`RX: ${text.trim()}`);
});
} catch (err: any) {
addLog(`Error: ${err.message}`);
}
};
const send = async () => {
if (!input.trim()) return;
try {
const data = new TextEncoder().encode(input + '\n');
await connection.send(data);
addLog(`TX: ${input}`);
setInput('');
} catch (err: any) {
addLog(`Send error: ${err.message}`);
}
};
const addLog = (message: string) => {
setLog(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
};
return (
<div className="usb-terminal">
<div className="terminal-header">
<h3>USB Serial Terminal</h3>
<button onClick={connect} disabled={connected}>
{connected ? 'Connected' : 'Connect'}
</button>
</div>
<div className="terminal-log">
{log.map((line, i) => <div key={i}>{line}</div>)}
</div>
<div className="terminal-input">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="Type command..."
disabled={!connected}
/>
<button onClick={send} disabled={!connected}>Send</button>
</div>
</div>
);
}Real-World Use Cases
IoT Sensor Dashboards
Manufacturing companies use Web Bluetooth to build real-time dashboards for IoT sensor networks. Maintenance technicians connect to BLE-enabled temperature, vibration, and humidity sensors directly from a tablet browser. The web app reads sensor data via GATT characteristics, displays live charts, and alerts when readings exceed thresholds. This eliminates the need for dedicated handheld readers or proprietary apps.
Medical Device Data Collection
Healthcare applications use Web Bluetooth to collect data from medical devices like blood glucose meters, pulse oximeters, and blood pressure monitors. The browser-based interface reads measurements from standard BLE health profiles, stores them in the patient's record, and generates trend reports. The standardized BLE health profiles ensure interoperability across device manufacturers.
3D Printer Control Interfaces
Web USB enables browser-based interfaces for 3D printers that use serial-over-USB communication. Users can upload G-code files, monitor print progress, adjust temperatures, and send manual commands—all from a web page. This approach works with popular printers that use vendor-specific USB protocols, bypassing the need for desktop software.
Hardware Debugging and Prototyping
Embedded systems developers use Web USB to communicate with development boards during prototyping. A web-based serial monitor replaces traditional terminal applications, providing cross-platform access without installation. Combined with Web Serial API (which uses OS-level serial drivers), developers can choose the API that best fits their hardware.
Best Practices
-
Always handle disconnection gracefully — Both Bluetooth and USB devices can disconnect unexpectedly (out of range, cable removed, battery dead). Implement reconnection logic and clearly communicate connection status to users.
-
Request only the permissions you need — Use specific filters rather than
acceptAllDevices: true. This provides a better user experience (shorter device list) and demonstrates responsible API usage. -
Implement proper error handling — Both APIs throw specific error types. Handle
NotFoundError(user cancelled),SecurityError(permission denied), andNetworkError(device disconnected) distinctly. -
Cache device references carefully —
BluetoothDeviceobjects persist across page reloads in some browsers. Usenavigator.bluetooth.getDevices()to retrieve previously paired devices without showing the chooser again. -
Respect battery life on BLE devices — Minimize the frequency of reads and notifications. Use appropriate connection parameters and unsubscribe from notifications when the data isn't being displayed.
-
Test with real hardware — Emulators can't fully replicate the behavior of physical devices, especially connection timing and error conditions. Test with actual devices on target platforms.
-
Provide clear UX for device selection — Show the user what device types your app supports before triggering the browser's device chooser. Include visual guides for identifying compatible devices.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Not handling disconnection | App freezes or crashes | Add disconnect event listeners |
| Wrong characteristic UUIDs | Read/write failures | Verify UUIDs against device documentation |
| Ignoring MTU limits | Packet fragmentation issues | Check device.gatt.mtu and split data |
| No reconnection logic | Poor user experience | Implement exponential backoff retry |
| Assuming device is always available | Crash on page resume | Check connection state before operations |
| Not releasing USB interfaces | Device locked for other apps | Always call releaseInterface and close |
| Excessive BLE polling | Battery drain on peripheral | Use notifications instead of polling |
Comparison with Alternatives
| Feature | Web Bluetooth | Web USB | Web Serial | Native Drivers |
|---|---|---|---|---|
| Device Discovery | BLE only | USB only | Serial ports | All |
| User Permission | Browser prompt | Browser prompt | Browser prompt | OS-level |
| Cross-Platform | Limited | Chrome only | Chrome, Edge | Platform-specific |
| Standard Profiles | BLE GATT | USB classes | Serial protocols | Any |
| Latency | Medium | Low | Low | Very Low |
| Installation Required | No | No | No | Yes |
| Security Model | Origin-bound | Origin-bound | Origin-bound | System-level |
Advanced Patterns
Multi-Device Connection Manager
class DeviceManager {
private devices: Map<string, HardwareDevice> = new Map();
private listeners: Set<(devices: HardwareDevice[]) => void> = new Set();
async connectBLE(filters: BluetoothRequestDeviceFilter[]) {
const btDevice = await navigator.bluetooth.requestDevice({ filters });
const device = new BLEDevice(btDevice);
await device.connect();
this.devices.set(device.id, device);
this.notifyListeners();
return device;
}
async connectUSB(filters: USBDeviceFilter[]) {
const usbDevice = await navigator.usb.requestDevice({ filters });
const device = new USBDeviceWrapper(usbDevice);
await device.connect();
this.devices.set(device.id, device);
this.notifyListeners();
return device;
}
disconnect(id: string) {
const device = this.devices.get(id);
if (device) {
device.disconnect();
this.devices.delete(id);
this.notifyListeners();
}
}
disconnectAll() {
for (const device of this.devices.values()) {
device.disconnect();
}
this.devices.clear();
this.notifyListeners();
}
onDevicesChanged(listener: (devices: HardwareDevice[]) => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners() {
const deviceList = Array.from(this.devices.values());
this.listeners.forEach(listener => listener(deviceList));
}
}Reconnection with Exponential Backoff
async function connectWithRetry(
connectFn: () => Promise<void>,
maxRetries: number = 5,
baseDelay: number = 1000
): Promise<void> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await connectFn();
return;
} catch (error) {
if (attempt === maxRetries - 1) throw error;
const delay = baseDelay * Math.pow(2, attempt);
console.log(`Connection attempt ${attempt + 1} failed, retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}Security Considerations
Both APIs require secure contexts (HTTPS) and user gesture activation. The browser shows a device chooser dialog that requires explicit user interaction. Web Bluetooth filters devices by service UUID or name, ensuring the app only sees relevant devices. Web USB filters by vendor ID, product ID, or device class.
Origin-bound permissions mean each website must independently request access to each device. This prevents cross-origin tracking and ensures users understand which site is accessing their hardware. Combined with the requirement for HTTPS, these security measures make hardware access from the browser safer than native driver installation.
Conclusion
Web Bluetooth and Web USB enable direct hardware communication from web applications, opening up possibilities for IoT dashboards, device configuration tools, medical device interfaces, and educational projects. While browser support is limited to Chromium-based browsers, these APIs are powerful for the right use cases.
Key takeaways:
- Web Bluetooth connects to BLE devices — sensors, wearables, IoT devices via GATT services and characteristics
- Web USB communicates with USB devices — microcontrollers, 3D printers, custom hardware via endpoint transfers
- Both require HTTPS and user gesture — security is built into the API design
- Handle disconnections gracefully — devices can disconnect at any time
- Use abstraction layers — normalize Bluetooth and USB communication behind a common interface
- Respect battery life — use notifications instead of polling for BLE devices
- Test with real hardware — emulators can't replicate physical device behavior
- Browser support is limited — Chrome and Edge only, with no Firefox or Safari support
Start with a simple proof-of-concept using a BLE heart rate monitor or Arduino over USB. The APIs are well-documented and the Chrome DevTools provide excellent debugging support for both Bluetooth and USB connections. As you build confidence, explore multi-device management and reconnection patterns for production-ready applications.