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:
- Request access to connected USB devices
- Open a connection and configure the device
- Send and receive data using standard USB transfer types
- Listen for device connection and disconnection events
- Release the device when finished
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:
- Zero installation: Users can interact with hardware directly from a web page without downloading software.
- Cross-platform: The same web app works on Windows, macOS, Linux, and ChromeOS without platform-specific builds.
- Rapid prototyping: Developers can build hardware configuration tools using familiar web technologies.
- Easy distribution: Updates deploy instantly via the web—no app store review process required.
- Accessibility: Web-based interfaces are generally easier to make accessible than native applications.
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:
- Device: A physical USB peripheral connected to the computer.
- Configuration: A set of interfaces that define how the device operates. A device can have multiple configurations but only one active at a time.
- Interface: A related set of endpoints that perform a specific function.
- Endpoint: A unidirectional data channel. Endpoints are either IN (device to host) or OUT (host to device).
- Transfer types: Control (configuration and commands), Bulk (large data), Interrupt (small time-sensitive data), and Isochronous (streaming data with timing guarantees).
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:
open()establishes a connection to the device.selectConfiguration()activates a specific configuration. Most USB devices have only one configuration (index 1).claimInterface()requests exclusive access to an interface. The operating system will not allow other programs to use the claimed interface simultaneously.
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:
- Only connect to devices you trust.
- Validate device descriptors before communicating.
- Implement strict protocol validation on received data.
- Be cautious with firmware update functionality.
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:
- Ensure the device is physically connected and powered.
- Verify your filter parameters match the device's vendor and product IDs.
- On Linux, you may need udev rules to allow non-root access to the device.
- On Windows, the device may need a compatible driver. WinUSB is the standard driver for WebUSB.
- Some devices are claimed by the operating system (e.g., HID keyboards) and cannot be accessed via WebUSB.
"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.