Hi — filing this from the perspective of someone building a browser-based firmware IDE (Node-BLUE, a visual code generator that compiles Arduino C++ and flashes boards in the browser via WebSerial / WebHID / WebUSB). I'd like to surface a set of related UX problems that I believe are shared by every browser-based embedded-firmware tool (Adafruit Web Serial ESPTool, MakeCode, MicroPython web IDE, Arduino Cloud Editor, web flashers shipped by Espressif, Nordic, NXP, etc.). I don't think any single decision here is hostile — but I do think the surface as a whole was never re-examined against this use case after the three device APIs were specced in separate groups.
For reference: my own native equivalent (a small "board server" running locally) lets the user click Upload once. Zero prompts. Because the OS scopes USB consent per physical device and exposes full descriptor metadata. The browser is the only environment where flashing a board I just plugged in needs 2+ user clicks and 3–5 permission dialogs.
The workflow(s) — there's more than one
Depending on what the board's app firmware exposes, the reflash path takes different shapes. All of them hit the same wall.
Path A: app firmware has a HID interface (NodeBlue's most common case).
Send a vendor-defined "reboot to bootloader" HID report → WebHID grant.
Wait ~1 s for the board to re-enumerate at its bootloader PID (different PID from app — e.g. SAMD21 0xE666 → 0x0666, nRF52840 Feather 0x8029 → 0x002A).
Open the bootloader's CDC ACM endpoint and run bossac / SAM-BA → WebSerial grant for the bootloader PID.
Path B: app firmware is CDC-only (no HID).
Common on Arduino-Zero-derived boards, Teensy LC, generic Pro Micros, anything using stock Adafruit TinyUSB without a HID interface.
Open the app's serial port at 1200 baud and close it — the standard Arduino "magic baud" hand-off → WebSerial grant for the app PID.
Wait ~1 s for re-enumeration.
Open the bootloader's CDC ACM → WebSerial grant for the bootloader PID. Two WebSerial grants for two PIDs on the same physical port. Even within the same API.
Path C: app is bricked or the user has held the reset button — board is already in bootloader.
Grant WebUSB / WebHID / WebSerial directly for the bootloader PID, depending on which API the bootloader speaks (DFU = WebUSB; HF2 = WebHID; SAM-BA = WebSerial).
In Path A with a fresh board: 2 button clicks (HID-grant click, then a second click for the bootloader-Serial grant — because transient activation is gone after the await ~1s for re-enumeration). 3+ dialogs.
In Path B: same 2 button clicks because the app-Serial grant consumes activation before the bootloader-Serial grant can fire.
In Path C: separate cold-start grant flow because none of the app-side grants apply.
A maximally-cautious "grant everything up front to avoid surprises later" implementation needs:
WebUSB(app), WebHID(app), WebSerial(app),
WebUSB(bootloader), WebHID(bootloader), WebSerial(bootloader)
= six pickers for one Upload click. We don't ship that, of course — but the fact that "do the right thing exhaustively" lands on six dialogs is itself the problem.
What's causing it
Three stacking design decisions:
Per-API permission silos. Grants via navigator.usb.requestDevice don't carry to navigator.hid.requestDevice or navigator.serial.requestPort, even for the same physical device. Once the page can do raw USB control transfers, "you may also send HID reports" or "you may also open a CDC ACM endpoint" is a strict subset of capability — there's no threat model in which the latter is more dangerous than what's already been granted.
Permission keys include PID. Grants are stored keyed by (VID, PID, serialNumber). A board entering bootloader is a "different device" from the browser's perspective, even though it's the same chip on the same physical port with the same user-meaningful identity. The user's mental model is "same device"; the browser disagrees.
Transient activation is consumed, not just required. requestDevice / requestPort consume the activation rather than just gating on its presence. Chrome empirically tolerates 2–3 chained prompts in a single click, but anything past an await setTimeout reliably fails — so the natural "send reset, wait for re-enumeration, grant bootloader" sequence is structurally impossible.
Metadata is gated to WebUSB — and serialNumber is missing from WebHID and WebSerial entirely
This part hurts even when permissions aren't an issue.
USBDevice exposes vendorId, productId, serialNumber, manufacturerName, productName, and deviceVersionMajor/Minor/Subminor (the bcdDevice). In Node-BLUE I overload bcdDevice as a board-side bootloader-version marker so the upload pipeline can route between HF2 and SAM-BA on Adafruit boards — that's a Node-BLUE convention, not a standard practice, and I'm not claiming any spec relies on this particular use of the field. The point is the broader one: every one of those values is descriptor data the OS already read at enumeration. Reading it back from the page should not require a full-control API grant.
HIDDevice and SerialPort.getInfo() expose VID and PID only. No serial number. No bcdDevice. No product string.
This means: to make any informed decision about which upload path to use, we need a WebUSB grant — even on a HID-only or Serial-only board where WebUSB has no purpose for the actual transfer. The grant exists only to read three integers and a string. The user is asked to consent to "this site may exchange data with USB devices" when all the site wants is the bcd byte. That's a security prompt without a security purpose.
But the missing serialNumber on HIDDevice and SerialPort is worse than ergonomic — it makes a class of common workflows impossible. Two concrete cases I've hit in production:
- Two boards of the same model, plugged in simultaneously. I'm building a radio bridge between two Adafruit Feather nRF52840s — one transmitter, one receiver, different firmware on each. Both enumerate as the same (VID, PID). The user needs to flash a specific one. WebHID and WebSerial expose only (VID, PID), so the picker shows two indistinguishable entries; once granted, there is no way for the page to tell which board it's talking to. The serial number is the only stable identifier. It's right there in the USB string descriptor that Chrome read at enumeration. The HID and Serial APIs just don't expose it.
The Arduino IDE doesn't solve this either — its Tools → Port menu shows COM7 / ttyACM0 with no other info, and the user gets to guess. Native tools that do solve it (PlatformIO, OpenOCD, MCUboot's tools) all rely on the USB serial number. Browsers should be able to as well.
- Windows COM-port renumbering when firmware descriptors change. This is the simracing-wheel / SimHub case, but it's actually generic for any composite HID+Serial firmware. Windows binds COM-port assignments to the USB descriptor hash, not to the device. If the user adds a new axis to their wheel (or any other USB-descriptor-affecting change) and reflashes, Windows assigns a new COM port number to the same physical board. From a SerialPort.getInfo() perspective, the old port disappears and a new one appears — same VID/PID, different "port", and the page has no way to know it's the same board. The user has to re-select the port from the dropdown every time their config changes.
Node-BLUE handles this transparently because the board server reads the OS-level serial number and tracks the board across reflashes. The Arduino IDE just makes the user re-pick. With serialNumber on HIDDevice / SerialPort.getInfo(), a browser-based IDE could do the same — without it, every web flasher in this space is silently worse than the desktop tool it replaces.
Why none of the existing escape hatches help
getDevices() / getPorts() are not gesture-gated, but they only return already-granted devices, so they can't bridge the bootloader-PID gap.
WebUSB filters accept class codes (e.g. { classCode: 0xFE, subclassCode: 0x01 } for DFU), but the picker still requires per-device selection — no dialog-count reduction.
A SerialPort granted for one (VID, PID) is invalidated when the same physical port re-enumerates with a different PID, so we can't pre-grant the bootloader from the app side.
The 1200-baud-touch trick doesn't reduce grants — it adds a WebSerial grant on the app PID.
Proposals, in increasing order of disruption
A. Treat (VID, serialNumber) as the device-identity key for grant purposes, across PID changes.
The cleanest fix. The user picked a piece of hardware; the firmware on it should be allowed to advertise different PIDs (app PID, bootloader PID, DFU PID) without invalidating consent. Matches how every OS-level USB stack and every native flashing tool works. No new API surface.
B. Unify grants across the three device APIs for the same device.
A WebUSB grant on (VID, PID, serial) should imply WebHID and WebSerial on the same device. Capability-wise, WebUSB ≥ WebHID ≥ WebSerial — the inclusion is in the safe direction. Could even be opt-in via a new option on requestDevice.
C. requestDevice / requestPort requires transient activation but doesn't consume it.
Allows chained pickers within the 5 s window — matches what Chrome already does in practice for the first 2–3, just formalized and extended past await setTimeout. This alone resolves the bootloader-reflash dialog count for tools that grant all three APIs upfront.
D. Expose serialNumber, manufacturerName, productName (and ideally bcdDevice) on HIDDevice and SerialPort.getInfo().
serialNumber is the load-bearing one — it resolves the two real-world cases above. The others are standard USB string descriptors already read at enumeration; exposing them costs no new I/O and adds no new permission surface.
E. Read-only USB device enumeration with descriptor metadata.
A new low-privilege API:
const devices = await navigator.usb.getDeviceDescriptors({
filters: [{ vendorId: 0x239A }]
});
// returns [{ vendorId, productId, deviceVersionMajor, deviceVersionMinor,
// deviceVersionSubminor, serialNumber, manufacturerName, productName,
// interfaces: [{ classCode, subclassCode, protocolCode }, …] }]
// No I/O capability. Gated by a single "this site may see your USB devices" prompt,
// or a one-off permission like geolocation.
This is what we actually need 80 % of the time. Knowing the bcd and serial is enough to route the upload (HF2 vs SAM-BA, which firmware blob, etc.) without ever opening a pipe. The current API forces full-control consent for what should be a read-only query.
F. Batch permission API.
await navigator.permissions.requestAll([
{ name: "usb", filters: [{ vendorId: 0x4D8 }] },
{ name: "hid", filters: [{ vendorId: 0x4D8 }] },
{ name: "serial", filters: [{ usbVendorId: 0x4D8 }] }
]);
One dialog, one consent, multiple grants. Strictly opt-in.
Reference point: how native tools behave
For comparison: my own native helper application (a "board server" running on the user's machine, talking to the browser via WebSocket) enumerates all attached USB devices via the OS, reads their full descriptors including bcdDevice and serialNumber, and reports them to the browser. Upload from the IDE is one click on the Upload button — no prompts at all, because OS-level USB consent is granted once per user (when the helper is installed). Every desktop Arduino tool (Arduino IDE, PlatformIO, OpenOCD, dfu-util, bossac) works the same way.
I'm completely on board with "the browser needs at least one user gesture per origin per device" — that's a real and useful security property. The current model goes much further than that, in ways that don't add safety but do add friction, and (in the missing-serialNumber case) actively prevent workflows that the desktop equivalents handle without issue. I'd like to see consent scoped to the user's mental model of a device, and device-identity metadata exposed at the API level where it's actually needed.
Happy to provide a reduced test case for any of the failure modes above (requestDevice → await sleep(1000) → requestPort → SecurityError; or two same-model boards plugged in and indistinguishable via getInfo()), and to test any of these proposals against the Node-BLUE upload pipeline if a prototype lands behind a flag.
— Etienne Saint-Paul (Developer of Node-BLUE @ GameSeed / ElectroSeed)
Hi — filing this from the perspective of someone building a browser-based firmware IDE (Node-BLUE, a visual code generator that compiles Arduino C++ and flashes boards in the browser via WebSerial / WebHID / WebUSB). I'd like to surface a set of related UX problems that I believe are shared by every browser-based embedded-firmware tool (Adafruit Web Serial ESPTool, MakeCode, MicroPython web IDE, Arduino Cloud Editor, web flashers shipped by Espressif, Nordic, NXP, etc.). I don't think any single decision here is hostile — but I do think the surface as a whole was never re-examined against this use case after the three device APIs were specced in separate groups.
For reference: my own native equivalent (a small "board server" running locally) lets the user click Upload once. Zero prompts. Because the OS scopes USB consent per physical device and exposes full descriptor metadata. The browser is the only environment where flashing a board I just plugged in needs 2+ user clicks and 3–5 permission dialogs.
The workflow(s) — there's more than one
Depending on what the board's app firmware exposes, the reflash path takes different shapes. All of them hit the same wall.
Path A: app firmware has a HID interface (NodeBlue's most common case).
Send a vendor-defined "reboot to bootloader" HID report → WebHID grant.
Wait ~1 s for the board to re-enumerate at its bootloader PID (different PID from app — e.g. SAMD21 0xE666 → 0x0666, nRF52840 Feather 0x8029 → 0x002A).
Open the bootloader's CDC ACM endpoint and run bossac / SAM-BA → WebSerial grant for the bootloader PID.
Path B: app firmware is CDC-only (no HID).
Common on Arduino-Zero-derived boards, Teensy LC, generic Pro Micros, anything using stock Adafruit TinyUSB without a HID interface.
Open the app's serial port at 1200 baud and close it — the standard Arduino "magic baud" hand-off → WebSerial grant for the app PID.
Wait ~1 s for re-enumeration.
Open the bootloader's CDC ACM → WebSerial grant for the bootloader PID. Two WebSerial grants for two PIDs on the same physical port. Even within the same API.
Path C: app is bricked or the user has held the reset button — board is already in bootloader.
Grant WebUSB / WebHID / WebSerial directly for the bootloader PID, depending on which API the bootloader speaks (DFU = WebUSB; HF2 = WebHID; SAM-BA = WebSerial).
In Path A with a fresh board: 2 button clicks (HID-grant click, then a second click for the bootloader-Serial grant — because transient activation is gone after the await ~1s for re-enumeration). 3+ dialogs.
In Path B: same 2 button clicks because the app-Serial grant consumes activation before the bootloader-Serial grant can fire.
In Path C: separate cold-start grant flow because none of the app-side grants apply.
A maximally-cautious "grant everything up front to avoid surprises later" implementation needs:
WebUSB(app), WebHID(app), WebSerial(app),
WebUSB(bootloader), WebHID(bootloader), WebSerial(bootloader)
= six pickers for one Upload click. We don't ship that, of course — but the fact that "do the right thing exhaustively" lands on six dialogs is itself the problem.
What's causing it
Three stacking design decisions:
Per-API permission silos. Grants via navigator.usb.requestDevice don't carry to navigator.hid.requestDevice or navigator.serial.requestPort, even for the same physical device. Once the page can do raw USB control transfers, "you may also send HID reports" or "you may also open a CDC ACM endpoint" is a strict subset of capability — there's no threat model in which the latter is more dangerous than what's already been granted.
Permission keys include PID. Grants are stored keyed by (VID, PID, serialNumber). A board entering bootloader is a "different device" from the browser's perspective, even though it's the same chip on the same physical port with the same user-meaningful identity. The user's mental model is "same device"; the browser disagrees.
Transient activation is consumed, not just required. requestDevice / requestPort consume the activation rather than just gating on its presence. Chrome empirically tolerates 2–3 chained prompts in a single click, but anything past an await setTimeout reliably fails — so the natural "send reset, wait for re-enumeration, grant bootloader" sequence is structurally impossible.
Metadata is gated to WebUSB — and serialNumber is missing from WebHID and WebSerial entirely
This part hurts even when permissions aren't an issue.
USBDevice exposes vendorId, productId, serialNumber, manufacturerName, productName, and deviceVersionMajor/Minor/Subminor (the bcdDevice). In Node-BLUE I overload bcdDevice as a board-side bootloader-version marker so the upload pipeline can route between HF2 and SAM-BA on Adafruit boards — that's a Node-BLUE convention, not a standard practice, and I'm not claiming any spec relies on this particular use of the field. The point is the broader one: every one of those values is descriptor data the OS already read at enumeration. Reading it back from the page should not require a full-control API grant.
HIDDevice and SerialPort.getInfo() expose VID and PID only. No serial number. No bcdDevice. No product string.
This means: to make any informed decision about which upload path to use, we need a WebUSB grant — even on a HID-only or Serial-only board where WebUSB has no purpose for the actual transfer. The grant exists only to read three integers and a string. The user is asked to consent to "this site may exchange data with USB devices" when all the site wants is the bcd byte. That's a security prompt without a security purpose.
But the missing serialNumber on HIDDevice and SerialPort is worse than ergonomic — it makes a class of common workflows impossible. Two concrete cases I've hit in production:
The Arduino IDE doesn't solve this either — its Tools → Port menu shows COM7 / ttyACM0 with no other info, and the user gets to guess. Native tools that do solve it (PlatformIO, OpenOCD, MCUboot's tools) all rely on the USB serial number. Browsers should be able to as well.
Node-BLUE handles this transparently because the board server reads the OS-level serial number and tracks the board across reflashes. The Arduino IDE just makes the user re-pick. With serialNumber on HIDDevice / SerialPort.getInfo(), a browser-based IDE could do the same — without it, every web flasher in this space is silently worse than the desktop tool it replaces.
Why none of the existing escape hatches help
getDevices() / getPorts() are not gesture-gated, but they only return already-granted devices, so they can't bridge the bootloader-PID gap.
WebUSB filters accept class codes (e.g. { classCode: 0xFE, subclassCode: 0x01 } for DFU), but the picker still requires per-device selection — no dialog-count reduction.
A SerialPort granted for one (VID, PID) is invalidated when the same physical port re-enumerates with a different PID, so we can't pre-grant the bootloader from the app side.
The 1200-baud-touch trick doesn't reduce grants — it adds a WebSerial grant on the app PID.
Proposals, in increasing order of disruption
A. Treat (VID, serialNumber) as the device-identity key for grant purposes, across PID changes.
The cleanest fix. The user picked a piece of hardware; the firmware on it should be allowed to advertise different PIDs (app PID, bootloader PID, DFU PID) without invalidating consent. Matches how every OS-level USB stack and every native flashing tool works. No new API surface.
B. Unify grants across the three device APIs for the same device.
A WebUSB grant on (VID, PID, serial) should imply WebHID and WebSerial on the same device. Capability-wise, WebUSB ≥ WebHID ≥ WebSerial — the inclusion is in the safe direction. Could even be opt-in via a new option on requestDevice.
C. requestDevice / requestPort requires transient activation but doesn't consume it.
Allows chained pickers within the 5 s window — matches what Chrome already does in practice for the first 2–3, just formalized and extended past await setTimeout. This alone resolves the bootloader-reflash dialog count for tools that grant all three APIs upfront.
D. Expose serialNumber, manufacturerName, productName (and ideally bcdDevice) on HIDDevice and SerialPort.getInfo().
serialNumber is the load-bearing one — it resolves the two real-world cases above. The others are standard USB string descriptors already read at enumeration; exposing them costs no new I/O and adds no new permission surface.
E. Read-only USB device enumeration with descriptor metadata.
A new low-privilege API:
const devices = await navigator.usb.getDeviceDescriptors({
filters: [{ vendorId: 0x239A }]
});
// returns [{ vendorId, productId, deviceVersionMajor, deviceVersionMinor,
// deviceVersionSubminor, serialNumber, manufacturerName, productName,
// interfaces: [{ classCode, subclassCode, protocolCode }, …] }]
// No I/O capability. Gated by a single "this site may see your USB devices" prompt,
// or a one-off permission like geolocation.
This is what we actually need 80 % of the time. Knowing the bcd and serial is enough to route the upload (HF2 vs SAM-BA, which firmware blob, etc.) without ever opening a pipe. The current API forces full-control consent for what should be a read-only query.
F. Batch permission API.
await navigator.permissions.requestAll([
{ name: "usb", filters: [{ vendorId: 0x4D8 }] },
{ name: "hid", filters: [{ vendorId: 0x4D8 }] },
{ name: "serial", filters: [{ usbVendorId: 0x4D8 }] }
]);
One dialog, one consent, multiple grants. Strictly opt-in.
Reference point: how native tools behave
For comparison: my own native helper application (a "board server" running on the user's machine, talking to the browser via WebSocket) enumerates all attached USB devices via the OS, reads their full descriptors including bcdDevice and serialNumber, and reports them to the browser. Upload from the IDE is one click on the Upload button — no prompts at all, because OS-level USB consent is granted once per user (when the helper is installed). Every desktop Arduino tool (Arduino IDE, PlatformIO, OpenOCD, dfu-util, bossac) works the same way.
I'm completely on board with "the browser needs at least one user gesture per origin per device" — that's a real and useful security property. The current model goes much further than that, in ways that don't add safety but do add friction, and (in the missing-serialNumber case) actively prevent workflows that the desktop equivalents handle without issue. I'd like to see consent scoped to the user's mental model of a device, and device-identity metadata exposed at the API level where it's actually needed.
Happy to provide a reduced test case for any of the failure modes above (requestDevice → await sleep(1000) → requestPort → SecurityError; or two same-model boards plugged in and indistinguishable via getInfo()), and to test any of these proposals against the Node-BLUE upload pipeline if a prototype lands behind a flag.
— Etienne Saint-Paul (Developer of Node-BLUE @ GameSeed / ElectroSeed)