MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Bluetooth and Web USB: Hardware Access from the Browser

Access Bluetooth and USB devices from the browser using Web Bluetooth and Web USB APIs.

Web APIsBluetoothUSBHardwareIoT

By MinhVo

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.

Hardware devices and web technology

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

Bluetooth connectivity

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

ClassCodeExamples
Audio0x01Speakers, microphones
Communications0x02Modems, serial adapters
HID0x03Keyboards, mice, gamepads
Mass Storage0x08Flash drives, external HDDs
Video0x0EWebcams, capture cards
Vendor Specific0xFFCustom 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>
  );
}

USB device connection

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

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

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

  3. Implement proper error handling — Both APIs throw specific error types. Handle NotFoundError (user cancelled), SecurityError (permission denied), and NetworkError (device disconnected) distinctly.

  4. Cache device references carefully — BluetoothDevice objects persist across page reloads in some browsers. Use navigator.bluetooth.getDevices() to retrieve previously paired devices without showing the chooser again.

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

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

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

PitfallImpactSolution
Not handling disconnectionApp freezes or crashesAdd disconnect event listeners
Wrong characteristic UUIDsRead/write failuresVerify UUIDs against device documentation
Ignoring MTU limitsPacket fragmentation issuesCheck device.gatt.mtu and split data
No reconnection logicPoor user experienceImplement exponential backoff retry
Assuming device is always availableCrash on page resumeCheck connection state before operations
Not releasing USB interfacesDevice locked for other appsAlways call releaseInterface and close
Excessive BLE pollingBattery drain on peripheralUse notifications instead of polling

Comparison with Alternatives

FeatureWeb BluetoothWeb USBWeb SerialNative Drivers
Device DiscoveryBLE onlyUSB onlySerial portsAll
User PermissionBrowser promptBrowser promptBrowser promptOS-level
Cross-PlatformLimitedChrome onlyChrome, EdgePlatform-specific
Standard ProfilesBLE GATTUSB classesSerial protocolsAny
LatencyMediumLowLowVery Low
Installation RequiredNoNoNoYes
Security ModelOrigin-boundOrigin-boundOrigin-boundSystem-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:

  1. Web Bluetooth connects to BLE devices — sensors, wearables, IoT devices via GATT services and characteristics
  2. Web USB communicates with USB devices — microcontrollers, 3D printers, custom hardware via endpoint transfers
  3. Both require HTTPS and user gesture — security is built into the API design
  4. Handle disconnections gracefully — devices can disconnect at any time
  5. Use abstraction layers — normalize Bluetooth and USB communication behind a common interface
  6. Respect battery life — use notifications instead of polling for BLE devices
  7. Test with real hardware — emulators can't replicate physical device behavior
  8. 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.