← Back to DevBytes

WebUSB API: Complete Guide

Introduction to WebUSB

The WebUSB API is a powerful browser interface that allows web applications to communicate directly with USB devices. This non-standard but widely supported API bridges the gap between web technologies and hardware, enabling developers to create web-based tools for device configuration, firmware updates, data transfer, and more—all without requiring users to install native applications or browser extensions.

What is WebUSB?

WebUSB provides a JavaScript interface to the Universal Serial Bus (USB) protocol. It exposes low-level USB operations—such as control transfers, bulk transfers, interrupt transfers, and isochronous transfers—to web pages running in secure contexts. The API follows a promise-based design, making it consistent with modern JavaScript patterns and easy to integrate into existing web applications.

At its core, WebUSB allows a web page to:

Why WebUSB Matters

Before WebUSB, interacting with USB hardware from a browser required workarounds such as companion native apps, browser plugins, or sending data through a local server. WebUSB eliminates these barriers, offering several key advantages:

Common use cases include flashing firmware to microcontrollers (like Arduino), configuring IoT devices, reading data from scientific instruments, controlling custom hardware, and building diagnostic tools for specialized equipment.

Browser Support and Prerequisites

WebUSB is currently supported in Chromium-based browsers, including Google Chrome, Microsoft Edge, Opera, and ChromeOS. Firefox and Safari do not currently support the API. Because WebUSB is a powerful feature requiring direct hardware access, it is only available in secure contexts—meaning your page must be served over HTTPS (or localhost during development).

Before diving into code, ensure you understand these USB concepts:

You can check for WebUSB support at runtime:

if ('usb' in navigator) {
  console.log('WebUSB is supported in this browser.');
} else {
  console.log('WebUSB is not available. Please use a Chromium-based browser.');
}

Getting Started with WebUSB

Requesting a Device

The entry point to WebUSB is navigator.usb. To access a device, you must first request it using navigator.usb.requestDevice(). This method must be called in response to a user gesture, such as a button click, to prevent pages from silently accessing hardware.

// HTML: <button id="connect">Connect to Device</button>

document.getElementById('connect').addEventListener('click', async () => {
  try {
    const device = await navigator.usb.requestDevice({
      filters: [
        { vendorId: 0x2341 } // Arduino SA
      ]
    });
    console.log('Connected to:', device.productName);
    console.log('Manufacturer:', device.manufacturerName);
    console.log('Serial Number:', device.serialNumber);
  } catch (error) {
    console.error('No device selected or access denied:', error);
  }
});

The filters array narrows the device picker to matching devices. You can filter by vendorId, productId, classCode, subclassCode, protocolCode, or serialNumber. If you omit filters entirely, the browser shows all connected USB devices.

Vendor and product IDs are 16-bit unsigned integers, typically represented in hexadecimal. You can find these IDs in device documentation or by inspecting the device in your operating system's USB device list.

Listening for Device Events

WebUSB provides events for devices being connected and disconnected. This is useful for updating your UI or automatically reconnecting when a device is plugged in.

navigator.usb.addEventListener('connect', (event) => {
  console.log('Device connected:', event.device.productName);
  // Optionally open the device automatically
});

navigator.usb.addEventListener('disconnect', (event) => {
  console.log('Device disconnected:', event.device.productName);
  // Update UI to reflect disconnection
});

Note that the connect event fires only for devices your page has previously been granted permission to access. A brand-new device still requires a user gesture and a call to requestDevice().

Opening and Configuring a Device

Once you have a device reference, you must open it, select a configuration, and claim an interface before transferring data.

async function connectToDevice(device) {
  await device.open(); // Open the device

  // Select configuration 1 (most devices use configuration 1)
  if (device.configuration === null) {
    await device.selectConfiguration(1);
  }

  // Claim interface 0
  await device.claimInterface(0);

  console.log('Device is ready for communication.');
  return device;
}

Each step is important:

Communicating with USB Devices

WebUSB supports all four USB transfer types. Each has a corresponding method on the USBDevice object. All transfer methods return promises that resolve with a USBInTransferResult or USBOutTransferResult object.

Control Transfers

Control transfers are used for device configuration, status reporting, and class-specific commands. They use endpoint 0 and follow a structured setup packet format.

async function sendControlTransfer(device) {
  // Send a control transfer to the device
  const setup = {
    requestType: 'vendor',    // 'standard', 'class', or 'vendor'
    recipient: 'device',      // 'device', 'interface', 'endpoint', or 'other'
    request: 0x01,            // Vendor-specific request code
    value: 0x0000,            // 16-bit value field
    index: 0x0000             // 16-bit index field
  };

  // OUT transfer: send data to device
  const data = new Uint8Array([0x01, 0x02, 0x03]);
  const result = await device.controlTransferOut(setup, data);
  console.log('Bytes sent:', result.bytesWritten);

  // IN transfer: receive data from device
  const inResult = await device.controlTransferIn(setup, 64); // 64 bytes max
  console.log('Status:', inResult.status);
  console.log('Data:', new Uint8Array(inResult.data.buffer));
}

The requestType field determines who interprets the request: standard USB requests, device class requests, or vendor-specific requests. The recipient specifies whether the request targets the entire device, a specific interface, or an endpoint.

Bulk Transfers

Bulk transfers move large amounts of data with error detection but no timing guarantees. They are commonly used for mass storage devices, printers, and serial communication adapters.

async function bulkTransferExample(device) {
  // Send data to endpoint 1 (OUT direction)
  const outData = new TextEncoder().encode('Hello, USB Device!');
  await device.transferOut(1, outData);

  // Receive data from endpoint 1 (IN direction)
  // Note: IN endpoints use the same number but with direction bit set
  const result = await device.transferIn(1, 64);
  
  if (result.status === 'ok') {
    const text = new TextDecoder().decode(result.data);
    console.log('Received:', text);
  }
}

Endpoint numbers in WebUSB are specified as plain integers (1–15). The direction is implied by the method: transferOut sends to OUT endpoints, and transferIn receives from IN endpoints. If a device has endpoint 1 OUT and endpoint 1 IN, both are addressed as 1—the method determines the direction.

Interrupt Transfers

Interrupt transfers are for small, time-sensitive data such as HID reports, keyboard input, or sensor readings. Despite the name, they are polled by the host at regular intervals guaranteed by the USB specification.

async function readInterruptData(device) {
  // Read from interrupt endpoint 2 (IN direction)
  // The length should match the endpoint's max packet size
  const result = await device.transferIn(2, 8);

  if (result.status === 'ok') {
    const data = new Uint8Array(result.data.buffer);
    console.log('Interrupt data:', data);
    return data;
  }
  return null;
}

// Polling loop for continuous interrupt reads
async function pollInterrupt(device) {
  while (device.opened) {
    try {
      await readInterruptData(device);
    } catch (error) {
      console.error('Transfer failed:', error);
      break;
    }
  }
}

Isochronous Transfers

Isochronous transfers deliver data at a guaranteed rate, making them suitable for audio and video streaming. They do not guarantee data integrity—lost packets are not retried.

async function isochronousTransferExample(device) {
  // Send isochronous data to endpoint 3 (OUT)
  const packets = [
    new Uint8Array([0x01, 0x02, 0x03]),
    new Uint8Array([0x04, 0x05, 0x06]),
    new Uint8Array([0x07, 0x08, 0x09])
  ];

  await device.isochronousTransferOut(3, packets, 0);

  // Receive isochronous data from endpoint 3 (IN)
  const result = await device.isochronousTransferIn(3, [64, 64, 64]);

  result.packets.forEach((packet, index) => {
    if (packet.status === 'ok') {
      console.log(`Packet ${index}:`, new Uint8Array(packet.data.buffer));
    }
  });
}

Selecting Alternate Interfaces

Some devices offer alternate settings for an interface, which can change endpoint configurations. You can switch between them using selectAlternateInterface().

async function switchAlternateSetting(device) {
  // Switch interface 0 to alternate setting 1
  await device.selectAlternateInterface(0, 1);
  console.log('Switched to alternate setting 1');
}

Real-World Example: Arduino LED Controller

Let's build a complete example that connects to an Arduino, sends commands to toggle an LED, and reads sensor data. This example assumes the Arduino is running a sketch that accepts commands over the serial USB interface.

<!DOCTYPE html>
<html>
<head>
  <title>Arduino LED Controller</title>
</head>
<body>
  <h1>Arduino LED Controller</h1>
  <button id="connectBtn">Connect to Arduino</button>
  <button id="ledOnBtn" disabled>LED On</button>
  <button id="ledOffBtn" disabled>LED Off</button>
  <button id="readSensorBtn" disabled>Read Sensor</button>
  <button id="disconnectBtn" disabled>Disconnect</button>
  <pre id="output"></pre>

  <script>
    let arduino = null;
    const output = document.getElementById('output');

    function log(message) {
      output.textContent += message + '\n';
    }

    function setButtonsEnabled(connected) {
      document.getElementById('connectBtn').disabled = connected;
      document.getElementById('ledOnBtn').disabled = !connected;
      document.getElementById('ledOffBtn').disabled = !connected;
      document.getElementById('readSensorBtn').disabled = !connected;
      document.getElementById('disconnectBtn').disabled = !connected;
    }

    async function connect() {
      try {
        arduino = await navigator.usb.requestDevice({
          filters: [{ vendorId: 0x2341 }] // Arduino vendor ID
        });

        await arduino.open();
        if (arduino.configuration === null) {
          await arduino.selectConfiguration(1);
        }
        await arduino.claimInterface(0);

        log(`Connected to ${arduino.productName}`);
        setButtonsEnabled(true);
      } catch (error) {
        log('Connection failed: ' + error.message);
      }
    }

    async function sendCommand(command) {
      if (!arduino || !arduino.opened) {
        log('Device not connected.');
        return;
      }

      try {
        const data = new TextEncoder().encode(command + '\n');
        await arduino.transferOut(1, data);
        log(`Sent: ${command}`);

        // Read response
        const result = await arduino.transferIn(1, 64);
        if (result.status === 'ok' && result.data.byteLength > 0) {
          const response = new TextDecoder().decode(result.data);
          log(`Response: ${response.trim()}`);
        }
      } catch (error) {
        log('Transfer failed: ' + error.message);
      }
    }

    async function disconnect() {
      if (arduino) {
        try {
          await arduino.releaseInterface(0);
          await arduino.close();
          log('Disconnected from device.');
        } catch (error) {
          log('Disconnect error: ' + error.message);
        }
        arduino = null;
        setButtonsEnabled(false);
      }
    }

    document.getElementById('connectBtn').addEventListener('click', connect);
    document.getElementById('ledOnBtn').addEventListener('click', () => sendCommand('LED_ON'));
    document.getElementById('ledOffBtn').addEventListener('click', () => sendCommand('LED_OFF'));
    document.getElementById('readSensorBtn').addEventListener('click', () => sendCommand('READ_SENSOR'));
    document.getElementById('disconnectBtn').addEventListener('click', disconnect);

    // Handle unexpected disconnections
    navigator.usb.addEventListener('disconnect', (event) => {
      if (event.device === arduino) {
        log('Device was unplugged.');
        arduino = null;
        setButtonsEnabled(false);
      }
    });
  </script>
</body>
</html>

This example demonstrates the full lifecycle: connecting, sending commands, receiving responses, handling disconnections, and cleaning up resources. The Arduino sketch would need to implement a serial command parser that responds to LED_ON, LED_OFF, and READ_SENSOR commands.

Working with Device Descriptors

WebUSB exposes device descriptor information directly on the USBDevice object after opening the device. This metadata helps you identify devices and adapt your logic accordingly.

async function inspectDevice(device) {
  await device.open();

  console.log('USB Version:', device.usbVersionMajor + '.' +
    device.usbVersionMinor + '.' + device.usbVersionSubminor);
  console.log('Device Class:', device.deviceClass);
  console.log('Device Subclass:', device.deviceSubclass);
  console.log('Device Protocol:', device.deviceProtocol);
  console.log('Vendor ID:', '0x' + device.vendorId.toString(16).padStart(4, '0'));
  console.log('Product ID:', '0x' + device.productId.toString(16).padStart(4, '0'));
  console.log('Product Name:', device.productName);
  console.log('Manufacturer:', device.manufacturerName);
  console.log('Serial Number:', device.serialNumber);

  // Inspect configurations
  device.configurations.forEach((config, i) => {
    console.log(`Configuration ${i}:`);
    config.interfaces.forEach((iface, j) => {
      console.log(`  Interface ${j}:`);
      iface.alternates.forEach((alt, k) => {
        console.log(`    Alternate ${k}:`);
        console.log(`      Class: ${alt.interfaceClass}`);
        console.log(`      Protocol: ${alt.interfaceProtocol}`);
        alt.endpoints.forEach((ep, l) => {
          console.log(`      Endpoint ${l}:`);
          console.log(`        Number: ${ep.endpointNumber}`);
          console.log(`        Direction: ${ep.direction}`);
          console.log(`        Type: ${ep.type}`);
          console.log(`        Max Packet Size: ${ep.packetSize}`);
        });
      });
    });
  });

  await device.close();
}

Inspecting endpoints is critical for building correct transfer calls. You need to know the endpoint number, direction, type, and maximum packet size before sending or receiving data.

Best Practices

Always Handle Errors Gracefully

USB communication is inherently unreliable. Devices can be unplugged mid-transfer, transfers can time out, and permissions can be revoked. Wrap all transfer calls in try-catch blocks and provide meaningful feedback to users.

async function safeTransfer(device, data) {
  try {
    const result = await device.transferOut(1, data);
    if (result.status !== 'ok') {
      throw new Error(`Transfer status: ${result.status}`);
    }
    return result;
  } catch (error) {
    if (error.name === 'NetworkError') {
      console.error('Device was disconnected during transfer.');
    } else if (error.name === 'NotFoundError') {
      console.error('Endpoint not found. Check device configuration.');
    } else {
      console.error('Transfer error:', error);
    }
    throw error;
  }
}

Release Resources Properly

Always release interfaces and close devices when you are done. Failing to do so can leave the device in an unusable state for other applications.

async function cleanup(device) {
  if (!device || !device.opened) return;

  try {
    // Release all claimed interfaces
    for (const config of device.configurations) {
      for (const iface of config.interfaces) {
        try {
          await device.releaseInterface(iface.interfaceNumber);
        } catch (e) {
          // Interface may not have been claimed
        }
      }
    }
    await device.close();
  } catch (error) {
    console.error('Cleanup failed:', error);
  }
}

// Clean up when the page unloads
window.addEventListener('beforeunload', () => {
  if (arduino && arduino.opened) {
    cleanup(arduino);
  }
});

Use User Gestures for Requests

The requestDevice() method requires a user gesture. Do not attempt to call it automatically on page load. Instead, provide a clear connect button and explain to users what will happen when they click it.

Validate Device Identity

After connecting, verify that the device is what you expect by checking vendor ID, product ID, and optionally the serial number. This prevents your code from sending inappropriate commands to a different device that happens to share an interface.

function validateDevice(device, expectedVendorId, expectedProductId) {
  if (device.vendorId !== expectedVendorId) {
    throw new Error(`Unexpected vendor ID: 0x${device.vendorId.toString(16)}`);
  }
  if (device.productId !== expectedProductId) {
    throw new Error(`Unexpected product ID: 0x${device.productId.toString(16)}`);
  }
  return true;
}

Provide Clear UI Feedback

USB operations can take time, and users need to understand what is happening. Show connection status, loading indicators during transfers, and clear error messages when things go wrong.

Use Appropriate Buffer Sizes

Match your buffer sizes to the endpoint's maximum packet size. Reading less than the max packet size can result in incomplete data, while reading significantly more wastes memory. You can find the packet size in the endpoint descriptor.

function getMaxPacketSize(device, endpointNumber, direction) {
  for (const config of device.configurations) {
    for (const iface of config.interfaces) {
      for (const alt of iface.alternates) {
        for (const ep of alt.endpoints) {
          if (ep.endpointNumber === endpointNumber && ep.direction === direction) {
            return ep.packetSize;
          }
        }
      }
    }
  }
  return 64; // Sensible default
}

Security Considerations

WebUSB was designed with security as a top priority. Understanding its security model helps you build safer applications and explain the model to your users.

Secure Context Requirement

WebUSB only works in secure contexts (HTTPS or localhost). This ensures that the USB communication is protected from man-in-the-middle attacks. Never attempt to use WebUSB over plain HTTP in production.

User Consent Model

Every device access requires explicit user consent through the browser's device picker dialog. A website cannot silently access USB devices. The user must select a device from the picker, and the permission is remembered for subsequent visits to that origin.

Origin Isolation

USB device permissions are tied to the web origin. A device paired with https://example.com cannot be accessed by https://malicious.com. This prevents cross-origin attacks.

Protecting Against Malicious Devices

While WebUSB protects against malicious websites, it cannot protect against malicious hardware. A compromised USB device could attempt to exploit the browser. To mitigate this risk:

WebUSB Descriptor for Automatic Prompts

Device manufacturers can include a WebUSB descriptor in their firmware. When a device with this descriptor is plugged in, Chrome can show a notification prompting the user to visit a specified URL. This creates a seamless onboarding experience.

// The WebUSB descriptor is defined in device firmware, not JavaScript.
// It contains:
// - bcdVersion: 0x0100 (WebUSB version 1.0)
// - bVendorCode: A vendor-specific request code
// - iLandingPage: Index of a URL descriptor

// When the device is plugged in, Chrome fetches the landing page URL
// and offers to navigate the user there automatically.

Troubleshooting Common Issues

Device Not Appearing in Picker

If your device does not appear in the browser's device picker, check the following:

"Access Denied" Errors

If you receive an access denied error, the device may be in use by another application. Close any software that might be using the device (serial monitors, native drivers, etc.) and try again. On Windows, you may need to use Zadig to replace the device driver with WinUSB or libusb.

Transfer Timeouts

If transfers hang indefinitely, the device may not be responding as expected. Verify that you are using the correct endpoint numbers and directions. Implement timeout logic using Promise.race():

async function transferWithTimeout(device, endpointNumber, length, timeoutMs) {
  const transferPromise = device.transferIn(endpointNumber, length);
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('Transfer timed out')), timeoutMs);
  });

  return Promise.race([transferPromise, timeoutPromise]);
}

// Usage: read with a 5-second timeout
const result = await transferWithTimeout(device, 1, 64, 5000);

Conclusion

The WebUSB API opens up an exciting world of hardware interaction directly from the browser. By understanding the USB protocol fundamentals, mastering the four transfer types, and following security and resource management best practices, you can build robust web applications that communicate with virtually any USB device. While browser support is currently limited to Chromium-based browsers, the API provides a powerful foundation for creating zero-installation, cross-platform hardware tools. As you build with WebUSB, remember to prioritize user consent, handle errors gracefully, and always clean up resources properly. With these principles in mind, WebUSB enables a new class of web applications that bridge the digital and physical worlds seamlessly.

— Ad —

Google AdSense will appear here after approval

← Back to all articles