Back to Knowledge Hub
Platform

Web Serial API Port Permission and Lifecycle

Request Web Serial access from a user action, separate permission from port readiness, bound streams, handle disconnects, and keep a safe fallback.

BotBrowser Team

Documentation

Want the structured docs for Platform?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

Web Serial lets a page request a user-selected serial port, but a chooser result is not a connected device, a trusted peripheral, or proof of ownership. BotBrowser can exercise page-owned permission and fallback branches in a controlled context; it cannot manufacture a physical serial device, grant operating-system permission outside browser mediation, or prove hardware I/O. A durable product contract is explicit: ask from a user action, open only a supported port, bound every frame, cancel and close streams on disconnect, and keep the task useful without serial hardware.

Web Serial user action, chooser permission, SerialPort open state, bounded streams, disconnect cleanup, and fallback

TL;DR

  • navigator.serial.requestPort({ filters: [{ usbVendorId: 0x2341 }] }) needs a secure context and transient user activation.
  • A selected SerialPort is an origin-scoped capability; it does not prove that the port is open, configured, connected, owned, or safe for every protocol.
  • Call port.open({ baudRate: 115200 }) with a reviewed configuration, then validate decoded frames before updating state or sending data onward.
  • navigator.serial connect and disconnect events describe browser-visible port changes; cancel readers, release locks, close the port, preserve drafts, and require deliberate retry.
  • Store coarse outcomes such as granted, unsupported, invalid-frame, or disconnected, not serial numbers, raw buffers, or inferred identity.

Contents

Ask at the point of use

Web Serial is restricted to secure contexts and requestPort() is intended to run during transient user activation. Put the request behind a named control that explains what connecting will enable. Feature detection only says that navigator.serial exists; it does not open a chooser or grant access. An embedded-frame policy, managed browser setting, unsupported platform, or operating-system rule can still make the request unavailable.

async function connectSerial() {
  if (!window.isSecureContext || !navigator.serial) return showSerialState('unsupported');
  setSerialState('requesting');
  try {
    const port = await navigator.serial.requestPort({
      filters: [{ usbVendorId: 0x2341 }],
    });
    await openSelectedPort(port);
  } catch (error) {
    showSerialState(error.name === 'NotFoundError' ? 'cancelled' : 'denied');
  }
}

connectButton.addEventListener('click', connectSerial);

Do not open a chooser on page load, from a timer, or in a loop after cancellation. Preserve the user's draft and offer another attempt only after a new action. For a related chooser-mediated hardware surface, WebUSB Device Access: Permissions, Interfaces, and Privacy separates a grant from open/configuration/claim readiness. WebHID Device Permission and Privacy Boundaries applies the same boundary to report collections, while Browser Permission Fingerprinting explains why a state is not a license to grant access or identify a person.

Separate permission from port readiness

requestPort() returns a SerialPort selected by the browser chooser. It does not mean that the port is still connected, that the requested baud rate is supported, that the device speaks the expected protocol, or that a write will reach a physical actuator. Treat selection, opening, reading, writing, and close as separate application states.

Before calling open(), decide which serial settings the product supports: baudRate, dataBits, stopBits, parity, and flowControl. Keep the accepted tuple narrow and documented. A successful open with the wrong framing can produce plausible but invalid bytes, so route protocol validation after the stream decoder rather than treating open() as “ready.”

async function openSelectedPort(port) {
  try {
    await port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none' });
    setSerialState('opened');
    await readSerialFrames(port);
  } catch (error) {
    showSerialState('unsupported');
    await closeSerialPort(port);
  }
}

navigator.serial.getPorts() can return ports previously granted to the origin, but it is not a complete inventory of connected hardware and does not replace a deliberate reconnect control. Re-check the current port and protocol before use. Do not silently choose the first returned port because a stored label resembles the current product name.

The Web Serial specification defines the browser-facing port and stream model. The MDN Web Serial API reference documents browser and platform availability. Neither source turns a port object into proof of identity, location, ownership, or successful device behavior.

Bound readable and writable streams

port.readable and port.writable expose asynchronous streams. Treat bytes as untrusted application input, not as a trusted protocol message. Use a TextDecoderStream or binary decoder with a maximum frame size, a delimiter or length field, a checksum when the protocol defines one, and a timeout for incomplete frames. Reject malformed frames before changing product state.

const MAX_FRAME_BYTES = 128;

async function readSerialFrames(port) {
  const reader = port.readable.getReader();
  const decoder = new Uint8Array();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      if (value.byteLength > MAX_FRAME_BYTES) {
        showSerialState('invalid-frame');
        continue;
      }
      acceptBoundedFrame(value);
    }
  } finally {
    reader.releaseLock();
  }
}

The example keeps the parser boundary visible; a production decoder should preserve partial frames between reads and enforce a total buffer limit. Do not log every byte, echo an arbitrary frame into a command interpreter, or classify a malformed frame as evidence that a person or vendor is suspicious. A category such as invalid-length, invalid-checksum, or timeout is usually enough for operations.

Writing needs the same discipline. Acquire port.writable.getWriter() only for the operation, validate the outgoing frame, and release the writer in finally. A resolved writer.write() proves only that the browser accepted bytes for its stream; it does not prove that an instrument moved, a display changed, or another application received them. For uncertain writes, preserve the local form and require an explicit retry instead of duplicating a command automatically.

Cancel a reader before closing the port. If a ReadableStream is locked, port.close() can wait or reject until the reader is released. Own the reader and writer in the task that created them, invalidate the task token on navigation or disconnect, and prevent a late read from repainting a newer fallback state as connected.

Reconcile connect and disconnect events

Listen for navigator.serial.addEventListener('connect', ...) and disconnect. A connect event means the browser observed a port becoming available; it does not authorize a silent chooser request or prove that the port matches the task. A disconnect event means the browser observed removal; it does not identify why the cable, driver, adapter, or operating system changed state.

On disconnect, mark the task terminal before awaiting cleanup, cancel the active reader, release writer locks, call port.close() best effort, remove listeners, preserve entered data, and show a deliberate reconnect button. Do not reconnect automatically or resend the last write: a command may have been partially delivered before the disconnect.

Use a small state machine such as idle, requesting, selected, opening, opened, reading, invalid-frame, disconnected, cancelled, and fallback. Each state should have a visible message, a next action, and a retention rule. This makes a disconnect during a write different from a chooser cancellation and keeps support notes precise.

For a long-running read, separate transport buffering from product state. The transport layer owns a Uint8Array of partial bytes and a deadline; the product layer receives only complete frames that pass length and checksum checks. Do not expose the transport buffer to analytics or error messages. When a delimiter is missing for 2 seconds, clear the partial buffer, emit timeout, and leave the form editable. A timeout is not evidence that the cable was pulled or that a particular device is defective.

A serial protocol often has commands that are safe to repeat and commands that are not. Mark that property in the feature contract before adding retries. A read request with a sequence number may be retried once after a transient stream error; a firmware write or actuator command should remain uncertain after disconnect and require a person to inspect the device before starting again. The browser cannot tell which class a frame belongs to, so the application must not infer idempotence from a resolved promise.

Treat configuration changes as new support rows. Changing baudRate from 115200 to 9600, enabling hardware flow control, or switching from 8-N-1 to another framing changes the bytes a parser sees. Record the exact tuple with the browser version and protocol revision, then run the same bounded fixture. Do not compare a successful row against a failed row while silently changing two variables; the first visible divergence becomes impossible to attribute.

Permission revocation can happen outside the page. A user may remove site access, an administrator may change policy, or a profile may be replaced while a tab remains open. On the next explicit Connect action, handle NotFoundError, SecurityError, and InvalidStateError as separate page outcomes where the browser exposes them, but do not claim that one name identifies the cause. Keep the ordinary fallback and explain that the current environment no longer exposes the requested port.

Do not use getInfo() as a login shortcut. usbVendorId, usbProductId, and a serial number can help support a known fixture, but they can also become a stable cross-session signal. If a person needs to choose “lab reader A,” store a local label and re-confirm the current port each time. If the service needs to associate a reading with an account, request that association separately from browser permission and state the retention period.

Test the page at each boundary: unsupported API, insecure origin, cancelled chooser, denied permission, selected port with wrong settings, open failure, partial frame timeout, checksum error, writer rejection, disconnect during read, disconnect during write, and reconnect after revocation. For every branch assert the visible state, preserved input, focus target, lock release, and redacted event. This fixture proves the page contract; hardware acceptance still belongs to the approved environment that supplies and observes the peripheral.

When a device reconnects, require a new user action before reopening unless the product has a reviewed, idempotent read-only flow. Re-run the port configuration and protocol handshake; a port that returns after a driver reset may not expose the same settings. Keep a user-assigned label separate from usbVendorId, usbProductId, serialNumber, or getInfo() output, which can reveal a model or physical unit.

For support, keep a compact transition record rather than a transcript of the wire. A useful row has the requested configuration, the first visible state, the error category, whether a reader or writer lock was released, and whether the local draft survived. For example, open-rejected with baudRate=115200 and draftPreserved=true directs support to inspect configuration without exposing a serial number or raw command. Compare rows by changing one input at a time: first secure-context policy, then permission, then port settings, then the simulated frame. This ordering prevents a reconnect failure from being mistaken for an authorization failure.

Backpressure also belongs in the page contract. A readable stream can deliver faster than the UI or parser consumes it. Cap queued bytes, stop reading when the cap is reached, and surface buffer-limit as a recoverable state; do not silently drop bytes while reporting success. A writer should serialize writes, reject a second command while the first lock is held, and release the lock in both success and error paths. These rules make a repeated fixture deterministic without pretending that a browser page controls the peripheral's firmware or transport timing. A blocked command leaves the form enabled, a released lock permits a deliberate retry, and a failed retry does not silently duplicate a non-idempotent command.

Keep the acceptance record tied to a visible user outcome. Record whether the Connect control remained reachable, whether draft text survived cancellation, whether focus returned to the retry control, and whether the raw frame was excluded from telemetry. A row such as disconnect-during-write, draftPreserved=true, focus=connect, and rawFrameStored=false is actionable without exposing port identifiers. Repeat that row with the same browser and protocol revision before comparing a different operating system or adapter. The comparison then separates a page-state regression from a transport-specific failure and gives support a bounded explanation for the user.

Minimize metadata and preserve a fallback

Separate browser permission, serial session, protocol consent, and remote upload. Reading a sensor locally does not authorize sending its values to a server. Tell the person what leaves the page, request a separate choice where appropriate, and allow the local task to finish when synchronization is declined.

An operational record usually needs unsupported, denied, cancelled, opened, invalid-frame, disconnected, or fallback, plus a timestamp and tested environment. It rarely needs SerialPort.getInfo(), USB identifiers, manufacturer strings, raw buffers, or a long-lived connect history. If support needs a diagnostic capture, make it short-lived, user-visible, access-controlled, and automatically deleted.

Keep manual entry, a file import, a simulated value, or a static configuration as a first-class fallback. Preserve the same essential data, focus order, validation, and error recovery without a live port. Do not gate sign-in, billing, account recovery, safety instructions, or required content on a physical serial device.

Review integrations that can copy data indirectly: analytics event schemas, crash reporters, session replay, URL builders, service-worker caches, and support screenshots. Replace getInfo() fields and raw frames with coarse outcomes unless a reviewed incident requires one value. A transformed serial number can still link visits, so hashing is not a default privacy fix.

Classify outcomes with a decision table

Observed stageWhat it establishesSafe next action
API absent, insecure context, or policy blockWeb Serial cannot start hereKeep the non-serial task and explain the environment limit
Chooser cancelled or permission deniedNo usable grant for this attemptPreserve the draft and wait for a new explicit action
Port selected but open() rejectsSelection did not produce a usable sessionShow supported settings, close best effort, and do not broaden access
Reader reports an invalid frameInput failed the application protocolDrop the frame, record its category, and keep the task usable
disconnect eventThe browser observed port removalCancel streams, close, preserve data, and offer deliberate retry
Valid bounded frameThe page accepted one application valueReport only that value; do not infer ownership or physical effect

Every row should land on an understandable page state. A chooser grant is not a connected port, an open stream is not a trusted protocol, and a valid frame is not proof that hardware performed an action.

BotBrowser capability and limitation

BotBrowser controlled contexts can verify a deterministic connect button, capability detection, chooser-result handling, bounded frame validation, disconnect cleanup, and the non-serial fallback. The advanced features documentation describes browser capabilities that can be reviewed in such a context. The result remains page-owned evidence: BotBrowser cannot provide a physical serial peripheral, grant operating-system permission, guarantee a port, or prove that bytes changed hardware.

Record the narrow result, for example:

{
  "api": "navigator.serial",
  "permission": "denied",
  "portState": "fallback",
  "framesAccepted": 0,
  "rawIdentifiersRetained": false,
  "physicalDevice": "not-asserted"
}

Repeat the fixture after changing one variable such as browser version, secure-context policy, permission result, serial configuration, simulated frame, or disconnect. Compare the first visible state divergence rather than claiming universal hardware support.

Sources

Practical conclusion

Use one explicit chooser action, keep permission separate from open and stream readiness, validate every frame, and cancel readers before closing after disconnect. Preserve the essential task without serial hardware and log the failure stage instead of port metadata. Recheck the fixture after browser, policy, driver, firmware, or permission changes; a page-level pass is not a promise of physical serial behavior.

#Web Serial API#Permissions#Serial Ports#Device Privacy#BotBrowser

This article is part of the guide: Web Platform Surfaces: A Consistency Review Map

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.