JavaScript WebUSB Table

PieceWhat it doesField note
navigator.usb.getDevices()The already-granted listdevices granted in earlier sessions; reopen on load - grants persist per-origin, handles do not
navigator.usb.requestDevice({filters})The picker gategesture required, filters mandatory (vendorId at minimum); two doors follow - browser consent, then OS driver ownership
device.open() + selectConfiguration(n)The setup stackpick from device.configurations; the value is a descriptor field, not an array index
device.claimInterface(n)The exclusive claimfails when a kernel driver owns the interface - system architecture feedback, not a code bug
controlTransferIn / controlTransferOutEndpoint zeroenumeration, class and vendor requests ride the management pipe; serialized and small
transferIn(ep, len) / transferOut(ep, data)Bulk plus interrupt pipespoll loop, no push event; check result.status before parsing result.data
isochronousTransferIn / OutThe stream pipeno retries, per-packet result arrays; audio, video and other bandwidth-promised traffic
clearHalt(direction, ep) + reset()Recoverya stalled endpoint stays dead until you clear it; status babble means your buffer was too small
Reference: the MDN WebUSB API. Raw USB for the long tail the OS classes miss: consent picker, open, selectConfiguration, claimInterface, then control transfers on endpoint zero and poll loops on your data pipes - the device descriptor is the contract.
Bottom line: claimInterface errors are driver-arbitration feedback - new firmware should ship the WebUSB platform descriptor (plus WCID markers for Windows) so the OS hands the interface to the browser with zero driver installs. Endpoint numbers and directions come from interface.endpoints, never hardcode them. For HID-class devices use WebHID; for USB-serial bridges use Web Serial; this API is for everything else.
Related tools: the WebHID table (the report-push sibling for HID class), the web serial table (CP210x and CH34x bridge chips), the web bluetooth table (the wireless sibling), the gamepad table (standard input), and the permissions table (grant lifecycle).

WebUSB is the browser API for arbitrary USB devices - not the ones the operating system already owns (keyboards, mass storage, webcams), but the long tail: custom instrument hardware, DFU bootloaders, embedded loggers, 3D printers, dev boards. It exposes the USB model raw: you pick a device through a consent picker, open it, select a configuration, claim interfaces, and then exchange bytes on endpoints - control transfers for the negotiation channel, bulk/interrupt for data, isochronous for streams. A device can even advertise a landing-page URL through the WebUSB platform descriptor, which is how some gadgets pop a browser tab the first time you plug them in.

Bottom line: the flow is a permission gate, then a stack of one-time setup calls, then transfer loops. navigator.usb.getDevices() returns previously granted devices; navigator.usb.requestDevice({filters}) (inside a user gesture) opens the picker; then await device.open(), await device.selectConfiguration(1), await device.claimInterface(n) - and from there controlTransferIn/Out for endpoint-zero traffic and transferIn/transferOut(endpoint, ...) for the data pipes. Everything is explicit and promise-based: unlike WebHID there is no push event model - you poll transferIn in a loop, and the device-side USB descriptor (endpoints, interfaces, classes) is your API documentation.

The integration reality: WebUSB is Chromium-only and it is the API where the operating system is your co-tenant. Kernel drivers claim interfaces before your page ever loads (HID class on Linux via hid-generic, vendor drivers on Windows), and claimInterface fails until the right driver relationship exists - the production answers are the WebUSB platform descriptor for new firmware, WinUSB-compatible descriptors (WCID) on Windows, and picking your battles on Linux. That makes WebUSB the sharper tool and the more frustrating one: infinite control, real OS friction, and debugging that spans chrome://device-log, lsusb -v and Wireshark. For HID-class devices, use WebHID instead; for serial-bridge chips, Web Serial; WebUSB is for everything else.

How to use

  1. Feature-detect and reconnect: if (!navigator.usb) branch to a Chromium note; const devs = await navigator.usb.getDevices() lists devices granted in earlier sessions - reopen them on load (grants persist per-origin, handles do not).
  2. Ask inside a gesture: const device = await navigator.usb.requestDevice({filters: [{vendorId: 0x2341}]}); - at least one filter is mandatory (vendorId alone is typical); rejection or an empty pick means the user said no, so show a calm empty state.
  3. Power the stack: await device.open(); await device.selectConfiguration(configValue) - pick from device.configurations (usually configuration 1); the configuration value comes from the descriptor, not an array index.
  4. Claim your interface: await device.claimInterface(interfaceNumber) - one claim per interface per open handle; if this rejects, an OS driver owns the interface and you are in driver-arbitration territory, not a code bug.
  5. Move bytes: await device.controlTransferIn/Out(setup, length) for endpoint-zero requests (GET_DESCRIPTOR, class and vendor requests), await device.transferIn(endpointNumber, length) / transferOut(endpointNumber, data) in your poll loop for bulk or interrupt pipes - endpointNumber is the bare number (1), direction is implied by the method; iso streams use isochronousTransferIn/Out.

Frequently asked questions

Why does claimInterface fail even though requestDevice succeeded, and what are my real options?

Browser consent and OS ownership are two different doors, and claimInterface is where the second one slams. The operating system binds drivers to interfaces by class: on Linux, hid-generic grabs HID-class interfaces and usbhid follows, usb-storage owns mass storage, and your page's claim fails with the interface already held; on Windows the story is WinUSB - a device is claimable if it ships a WinUSB-compatible (WCID) descriptor or an explicit WinUSB driver install, otherwise Microsoft's default drivers hold it; macOS's IOKit claims standard classes similarly. Your options, in order of sanity. First, check what you are claiming: alternate interfaces exist (interface.alternates), and some devices put the vendor endpoint on an alt setting the kernel ignores - device.selectAlternateInterface(claimed, alt) after claiming the base. Second, new firmware gets the clean path: add the WebUSB platform descriptor (a BOS descriptor entry pointing at your landing-page URL) and, for Windows, the WCID markers - a device that advertises WebUSB gets a Microsoft-signed WinUSB driver automatically, which is why well-behaved WebUSB gadgets work with zero driver installs. Third, class collision triage: for HID-class interfaces switch to WebHID (different API, same consent model, arbitration handled); for USB-serial bridges use Web Serial; the device's own class per interface decides, and one device can mix (claim interface 0 via WebUSB, interface 1 via WebHID is not possible across APIs - pick the API by the interface you need). Fourth, on Linux dev setups only, a udev rule or driver blacklist reassigns ownership - fine for your bench, not shippable to users. The meta-rule: claimInterface errors are system architecture feedback, read them that way.

How do endpoint numbers, directions and packet sizes actually work in the transfer methods?

USB encodes direction inside the endpoint address byte: bit 7 is direction (1 = IN, device-to-host, so 0x81 is endpoint 1 IN), bits 3:0 the number. WebUSB deliberately unbundles this: transferIn(endpointNumber, length) takes the bare number 1 and the method name carries the direction - you are constructing 0x81 without writing it. Each interface's descriptor lists its endpoints (interface.endpoints[].endpointNumber, .direction, .type) - read them, do not hardcode: firmware revisions renumber endpoints silently and the descriptor is the contract. Transfer types matter as much as numbers: control (endpoint 0 only, setup-packet payloads via controlTransferIn/Out), bulk (guaranteed delivery, no bandwidth promise - the workhorse for data), interrupt (small bounded-latency packets - HID and status channels; on WebUSB you still poll these with transferIn, unlike WebHID's push), isochronous (fixed bandwidth, no retry - audio/video; isochronousTransferIn returns a structure of per-packet results because iso frames arrive as a burst of packets, not one stream). Sizes: declare length as the max packet size multiple you can accept (wMaxPacketSize from the descriptor); short packets signal the end of a transfer for bulk, which is how you frame variable-length messages; and a stalled endpoint surfaces as status 'stalled' in the result - clear it with device.clearHalt(direction, endpointNumber) or the endpoint stays dead. Check result.status before parsing result.data: 'ok' is not the only exit, and 'babble' (device overran your buffer) means your length was too small, not that the data is corrupt.

What is endpoint zero and what belongs in a control transfer?

Endpoint zero is the one pipe every USB device has before you configure anything - the management channel where USB itself lives. The host drives it with control transfers, each a setup packet: bmRequestType (direction, recipient - device/interface/endpoint - and type: standard/class/vendor), bRequest (the operation), plus value, index and length fields. Standard requests are the protocol's own verbs: GET_DESCRIPTOR (this is how the host learns configurations, interfaces, endpoints and strings - the whole enumeration dance runs here before you claim anything), SET_ADDRESS, SET_CONFIGURATION, SET_INTERFACE, GET_STATUS. Class requests are the device family's protocol on the same pipe: a CDC device answers class requests for line coding, a HID device for its report descriptor, a DFU bootloader for its state machine. Vendor requests are whatever your hardware team defined. On WebUSB you use this for: reading descriptors the browser did not surface (device.controlTransferIn({requestType: 'standard', recipient: 'device', request: 0x00, value: 0x0100, index: 0}, length) is a raw GET_DESCRIPTOR), driving class protocols (DFU's dnload/getstate sequence is the classic - that is how browser-based firmware flashers work), and vendor-specific control commands your device defines. Rules of the road: control transfers are small (wMaxPacketSize on ep0, traditionally 8/16/32/64 bytes per packet stage), they are serialized (the device processes one at a time - do not fire a burst and hope), and the setup object's fields are exact-width numbers - value 0x0100 is descriptor type 01 (device) in the high byte, index 0; getting the byte packing wrong yields a valid-looking empty response, the quietest failure mode in the API.

How do I structure a reliable read loop, and how does WebUSB degrade where it is unsupported?

The read loop is the heart of a WebUSB app and there are three patterns. Poll-and-sleep: an async while loop - while (open) { const r = await device.transferIn(ep, size); handle(r); await new Promise(r => setTimeout(r, 0)); } - transferIn resolves when data arrives (it is not busy-polling at the wire level; the host controller parks the transfer), so the timeout is a scheduling yield, not a throttle. Cancellation: guard with a flag plus device forgotten on disconnect (device forgets via the disconnect event on navigator.usb - re-enumerate from getDevices()), and always let an in-flight transferIn reject naturally on unplug rather than abandoning the promise. Framing: bulk streams have no message boundaries - either the device uses short packets as terminators (request exactly one wMaxPacketSize chunk and treat < max as end-of-message) or you implement a length-prefixed protocol on top; the USB layer will not do it for you. Backpressure: transferOut rejects when the device's buffers are full (endpoint halted or NAKing forever) - queue outbound data, never fire-and-forget in a tight loop, and clearHalt after a stall before resuming. Degradation: navigator.usb is Chromium-only - feature-detect once at boot; the honest fallback ladder is (1) WebHID or Web Serial if the device's class actually lives there (say so explicitly in the UI), (2) a download link for the native vendor tool, (3) a read-only/demo mode. What does not work: polyfilling USB in JavaScript (WebUSB needs host-controller access browsers do not expose) or pretending Firefox users have the feature. Design the page so the unsupported path is a clear capability statement with alternatives, and keep every transfer result's status checked - reliability in WebUSB is a state machine you write, not a property of the transport.

Related tools