JavaScript WebHID Table
| Piece | What it does | Field note |
|---|---|---|
navigator.hid.getDevices() | The already-granted list | devices from earlier sessions; reconnect silently at boot - the grant persists per-origin, the open handle does not |
navigator.hid.requestDevice({filters}) | The consent gate | user gesture mandatory, at least one filter mandatory (vendorId/productId or usagePage/usage); cancel resolves an empty array |
HIDDevice | Identity plus descriptor handle | vendorId, productId, productName and collections; the descriptor in collections is your API documentation |
device.open() / close() | The handle | open before any I/O - inputreport listeners attached before open() silently never fire |
the inputreport event | The push | device-to-page reports arrive without polling; e.reportId carries the id, e.data starts after it (one byte shorter than a libusb trace) |
device.sendReport(reportId, bytes) | Output reports | the id travels as an argument, never prepend it to the buffer; single-report devices use id 0 |
sendFeatureReport / receiveFeatureReport | The config channel | get/set transactions for firmware version, calibration, mode switches; many consumer devices implement none |
device.collections | The descriptor parse | usagePage/usage coordinates and report ids/sizes come from the device declaration - parse it, do not guess |
WebHID is the browser API for talking to human interface devices - gamepads that no standard Gamepad API mapping covers, custom macro pads, exotic flight sticks, foot pedals, stream-deck-style button grids. HID is the USB/Bluetooth class built for exactly these devices, and the class carries its own protocol: report descriptors (a compact byte program the device ships in its firmware) describe what the device can send and receive, and reports are the small numbered packets that flow. WebHID hands you those reports raw - no driver install, no native app, no kernel code - which is why it exists at all: the alternative for a device the standard classes miss was Chrome apps (dead) or a signed native binary.
Bottom line: the API is a consent gate followed by two unidirectional report streams and one bidirectional config channel. navigator.hid.getDevices() lists devices the user granted in a previous session; navigator.hid.requestDevice({filters}) shows the browser picker (it must run inside a user gesture) and returns the newly chosen device; you device.open() it, then listen for inputreport events (device to page), call sendReport() (page to device), and use sendFeatureReport/receiveFeatureReport for the configuration channel. The descriptor - device.collections - tells you what report IDs and usages mean, so your parsing is driven by the device's own declaration, not guesswork.
The integration reality: WebHID is Chromium-only (Chrome, Edge, Opera; nothing in Firefox or Safari, no polyfill possible), persisted per-origin per-browser, and every single device is individually consented - there is no wildcard grant. Real-world wins live where the Gamepad API gives up: devices with vendor-specific usages, nonstandard button layouts, or config software that used to require a download. Debugging is mostly browser-external: chrome://device-log shows the descriptor and connection story, and a bad device-side descriptor will out-bug anything your JavaScript does. Choose WebHID over raw WebUSB whenever the device actually speaks HID - you get the report push, the descriptor parse, and OS-level arbitration for free.
How to use
- Feature-detect and list the incumbents: if (!navigator.hid) show a Chromium note; const devs = await navigator.hid.getDevices() returns devices from earlier grants - reconnect them silently on page load, because the grant survives but the open handle does not.
- Ask for new hardware inside a gesture: const [device] = await navigator.hid.requestDevice({filters: [{vendorId: 0x1234, usagePage: 0xFF00, usage: 0x01}]}); - omitting filters throws a TypeError; the picker shows only matching devices, and cancelling resolves an empty array.
- Open before any I/O: await device.open() - inputreport listeners attached before open() will still silently never fire; open() claims the device from the browser's perspective and close() releases it on teardown.
- Consume the push: device.addEventListener('inputreport', e => { parse(e.data, e.reportId) }) - reports arrive when the device sends them, no polling; e.data is a DataView whose payload starts AFTER the report id (the id travels in e.reportId, not in the buffer).
- Talk back: await device.sendReport(reportId, new Uint8Array([...])) for output reports, and await device.sendFeatureReport(id, bytes) / receiveFeatureReport(id) for the config channel - read the descriptor in device.collections first so your report ids and byte layout come from the device's own declaration.
Frequently asked questions
Why does requestDevice throw or return nothing, and how do filters actually work?
Three separable failures. A thrown TypeError means you called it with no filters - the spec demands at least one, because an unfiltered picker would be an all-devices consent dialog; filters are the scope of what you may ask for, not a search optimization. An empty resolved array is the user closing the picker without choosing - design for it, it is the polite no. A SecurityError or a picker that never opens means the call happened outside a user gesture: requestDevice must run in a click/keypress handler (or shortly downstream of one), which kills the boot-time auto-connect dream - the pattern instead is getDevices() at boot for past grants plus a visible Connect button for new ones. Filter mechanics: vendorId and productId are the 16-bit USB IDs (0x1234 style), usagePage and usage are HID descriptor coordinates - a usage filter like (0x01, 0x05) means gamepad control, 0xFF00+ pages are vendor-defined. Filters OR across entries, AND within: [{vendorId: 0x1234}, {usagePage: 0x0F}] matches either condition. The pragmatic tip: ship one filter for your vendor id and, if you must discover devices you have not catalogued, a second filter for the vendor usage page - and remember every matched device still needs its own explicit human click, there is no silent scan.
Where did my reportId byte go, and why is e.data one byte shorter than the descriptor says?
This is the classic first-hour WebHID bug, and the descriptor is telling the truth. HID frames reports with a report id when (and only when) the device's descriptor declares multiple report ids: on the wire, and in every USB interrupt transfer, that one-byte id prefixes the payload. WebHID strips it - the id arrives as e.reportId and e.data contains only what follows. So a 16-byte report under id 0x02 shows up as e.data.byteLength === 16 (not 17), and code that was written against a native HID or libusb trace (where the id is in the buffer) mis-parses everything by one byte. Symmetrically on the way out: device.sendReport(0x02, bytes) takes the id separately, and you must not prepend it yourself. The zero case: devices with a single report (no ids declared) use reportId 0 everywhere, and e.data is the full payload. The reliable move is to parse device.collections - each collection exposes reports with their ids, item counts and sizes - and derive your offsets from that structure rather than from a packet capture of a different transport. If lengths still look off by more than one, suspect padding: reports are byte-aligned to the descriptor's declared report size (bits round up), so a 7-bit field costs a whole byte.
What is the feature report channel for, and when do I use it instead of input/output reports?
Feature reports are the control plane; input and output reports are the data plane. Conceptually: input reports stream state (button down, axis moved), output reports drive state (set LEDs, rumble), and feature reports read or write configuration - firmware version, device name, calibration, mode switches, the things you query once or set rarely. Mechanically they differ in both transport and shape: feature transfers happen over the HID control endpoint as get/set transactions (request-response, initiated by the host - the page - not pushed by the device), and a feature report is often a struct of mixed fields rather than a stream sample. The API: await device.receiveFeatureReport(reportId) returns a DataView (read the first byte to see which id came back on devices that multiplex), and await device.sendFeatureReport(reportId, bytes) writes. Practical patterns: identifying a device on connect (query a vendor feature report for model/firmware instead of trusting productName strings), switching a macro pad between layers (feature write, then output reports keep streaming), and reading sensors' calibration blobs. Two gotchas: many consumer devices simply implement no feature reports - receiveFeatureReport rejects, and that is a device fact, not a bug in your code; and feature ids share the same id space as input/output ids, so a feature 0x03 and an input 0x03 are different reports that happen to share a number.
How does WebHID compare with WebUSB and Web Serial, and how should I degrade on Firefox and Safari?
Pick by device class, not preference. If the device enumerates as HID (check any OS device inspector, or just try it - HID is the default class for input-ish gadgets), WebHID is strictly better: you get the report push model (inputreport events, no polling loop), the descriptor parse (collections tell you ids and layouts), and the OS's HID arbitration stack working for you. WebUSB is the right tool when the device speaks a vendor or class protocol that is not HID - custom bulk endpoints, DFU flashing, vendor control requests - and it costs you: you manage endpoint numbers, poll transfers in a loop, and fight kernel drivers that claim interfaces first (Web Serial overlaps here for anything behind a CP210x/CH34x USB-serial bridge, which is its own genre of debugging). All three share the same consent architecture - gesture-scoped picker, per-origin persistence, individual device grants. Degradation: feature-detect (navigator.hid / navigator.usb / navigator.serial - all currently Chromium-only), and design the fallback as a capability question, not an error: Firefox and Safari users of a macro pad need the desktop config app, so your web page should say exactly that, link it, and keep working in read-only or demo mode where possible. What you must not do is fake support or hang the page probing: one feature-detect at boot, one UI branch, and your permission story (see the permissions table for how the three APIs' grants show in site settings) stays honest. And before writing any parser, log chrome://device-log once - the descriptor dump there resolves more first-hour confusion than any amount of console.log.