JavaScript Web Serial Table
| Piece | What it does | Field note |
|---|---|---|
requestPort() | The picker | Gesture-gated device chooser - filters by usbVendorId/ProductId so users see their board, not every dongle |
getUserSelectedPorts() | The persistent grant | Reconnects returning users - pair with remembered filter metadata in localStorage |
open({baudRate}) | The match | The ONE config that must equal firmware - 9600 legacy, 115200 modern; 8-N-1 defaults cover the rest |
readable/writable | The streams | A pair of web streams - pipeThrough(TextDecoderStream) for ASCII, hand-parse Uint8Array for binary |
writer lock | The write discipline | One writer at a time, release before close - queue commands, never interleave writers |
connect/disconnect | The lifecycle | Unplug mid-session rejects the reader and hangs writes - close defensively, offer reconnect |
port.forget() | The revoke | Your settings page should offer it - grants are per-origin and user-controlled |
Chromium truth | The support | Chrome/Edge desktop only - Safari/Firefox/mobile absent; feature-detect and keep the manual fallback |
Web Serial is the browser's license to talk to physical devices over their serial ports - Arduinos, 3D printers, CNC controllers, lab instruments, POS terminals - without installing a native app or a driver shim. The model is permission-first: navigator.serial.requestPort() shows a device picker (from a user gesture), you open the chosen port with a baud rate, and from there the port is just a pair of web streams - a ReadableStream of bytes coming in and a WritableStream going out.
Bottom line: the whole API is three moves. Pick (requestPort inside a click handler - like fullscreen and payment, it is gesture-gated), open (port.open({ baudRate: 115200 }) - the ONE config that must match the device or you get garbage bytes), and pipe (port.readable / port.writable connect straight to TextDecoderStream/TextEncoderStream for ASCII devices, or hand-parse bytes for binary protocols). Everything else - flow control, data bits, stop bits - has sane defaults unless your manual says otherwise.
The honest part: Web Serial is Chromium-on-desktop territory - Chrome and Edge ship it, Safari and Firefox do not, and mobile is mostly absent, so feature-detect ('serial' in navigator) and keep the manual upload/download fallback your users already have. Permission is per-origin and ephemeral: the browser forgets granted ports unless the user checks 'allow on every visit', which is why the persistent path runs through getUserSelectedPorts() plus your own remembered port metadata, and why port.forget() exists for the settings page you should build.
How to use
- Pick a port inside a gesture: button.onclick = async () => { const port = await navigator.serial.requestPort(); } - the chooser lists devices the OS knows about; filter with filters: [{ usbVendorId, usbProductId }] so users see their Arduino, not every dongle on the bus.
- Open with matching parameters: await port.open({ baudRate: 115200 }) - baud must equal the device's firmware setting (9600 for old GPS and radio gear, 115200 for most modern boards); add dataBits/stopBits/parity/flowControl only when the manual deviates from 8-N-1 defaults.
- Read through a decoder pipe: port.readable.pipeThrough(new TextDecoderStream()).pipeTo(textStreamConsumer) turns incoming bytes into strings for ASCII line protocols; for binary protocols skip the decoder and parse Uint8Array chunks from the reader directly, framing on your protocol's delimiters.
- Write through an encoder: const writer = port.writable.getWriter(); await writer.write(new TextEncoder().encode('G28 ')); writer.releaseLock() - keep the lock discipline: one writer at a time, release before closing, and queue commands rather than interleaving writers.
- Manage the port lifecycle: remember filter metadata in localStorage so returning users can reconnect without re-picking (navigator.serial.getPorts() for already-granted ports, getUserSelectedPorts() for the newer persistent grant), offer port.forget() in settings, and listen for navigator.serial 'connect'/'disconnect' events to survive unplug-replug mid-session.
Frequently asked questions
Why does requestPort() throw inside setTimeout but work in the click handler?
The same transient user activation gate as fullscreen and Payment Request: opening a device picker is high-stakes UI, so the browser requires a fresh gesture. Activation expires after a few seconds or one consuming call, so the classic failure is picking the port after an await - requestPort() itself must be the first thing the click does. If you need device metadata before showing the picker (you usually do not - the filters do that), fetch it BEFORE the gesture, never after. The rejected promise with NotAllowedError is the platform telling you the gesture budget is spent, not a bug to retry around.
My device returns garbage characters - what is wrong?
Almost always a baud-rate mismatch, and the garbage is diagnostic: readable ASCII at slightly wrong baud means one side is off by a common step (9600 vs 115200), while pure replacement characters usually mean binary data meeting a TextDecoder - check whether the device speaks ASCII at all. After baud, walk the rest of the 8-N-1 assumption: some industrial gear wants 7 data bits, even parity, or 2 stop bits; some wants hardware flow control (rtstc) and silently chokes without it. The honest debug loop: open at the manual's exact parameters, echo bytes as hex (not text), and only then decide whether the problem is serial config or your protocol framing.
Do I really need to handle disconnect events?
Yes, and the failure mode is nastier than a null: when the cable is pulled mid-session, the reader rejects with a framing error, pending writes hang, and naive code keeps queuing commands into the void. Listen to navigator.serial 'disconnect' (and 'connect' for re-plug), look up the matching port in the event, close it defensively in a try/catch (closing a dead port throws), surface a reconnect button, and keep the port filter metadata so reconnect is one gesture away. Devices that power-cycle on reconnect present a NEW port handle even when the cable never moved - which is why the disconnect handler matters even for tethered setups.
Web Serial vs WebUSB - which one for my device?
Serial if the device speaks serial (a UART, a USB-serial bridge like CP210x/CH340, an Arduino's COM port) - the serial abstraction handles line discipline, decoders and the baud model your firmware already assumes. WebUSB is for devices that enumerate as custom USB classes: you negotiate interfaces and endpoints yourself, gaining raw control at the cost of writing a mini driver in JavaScript. Many real devices expose both - a board with a USB-serial chip AND a custom vendor interface - and the practical tiebreaker is documentation: if you can buy the device's serial protocol from its manual, Web Serial gets you there in an afternoon; if the protocol only exists as a vendor SDK's USB traces, that is WebUSB (and often WebHID) territory.