Web Serial API: Connect Microcontrollers with JS

The Web Serial API provides a direct bridge between web applications and serial hardware devices, such as microcontrollers, without requiring native companion software or intermediate local servers. By exposing standard serial communication interfaces through modern browser APIs, developers can discover, connect, read from, and write to devices like Arduino, ESP32, and Raspberry Pi Pico directly from client-side JavaScript. This article explains how the API establishes this hardware-to-browser connection, its underlying security model, and the core implementation steps required for bidirectional data transfer.

The Core Communication Model

Traditional web browsers operate in an isolated sandbox with no direct access to underlying operating system hardware. The Web Serial API safely bridges this gap by exposing the operating system’s serial ports via the navigator.serial interface. When a web page requests access, the browser acts as an intermediary, translating JavaScript streams into raw serial byte streams managed by the operating system’s serial drivers.

Security and User Permissions

Hardware access requires strict security measures. The Web Serial API enforces two fundamental security mechanisms: 1. Secure Contexts: The API is only accessible over HTTPS or localhost. 2. Explicit User Consent: A website cannot automatically scan for or connect to serial devices. Connection initiation requires a transient user activation (such as a button click) calling navigator.serial.requestPort(). This opens a native browser prompt allowing the user to select the specific connected microcontroller.

Requesting and Opening a Port

To establish communication, JavaScript prompts the user to select a device, optionally filtering by USB vendor and product IDs. Once selected, the port is opened with defined serial transmission parameters:

// Request device selection
const port = await navigator.serial.requestPort();

// Open the connection with specific serial parameters
await port.open({ 
  baudRate: 115200, 
  dataBits: 8, 
  stopBits: 1, 
  parity: "none" 
});

Reading Data from the Microcontroller

Data transfer relies on the standard Streams API. Incoming bytes are handled through port.readable. To process text data, the raw byte stream can be piped through a TextDecoderStream:

const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// Read incoming serial data in a loop
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    reader.releaseLock();
    break;
  }
  console.log("Received from microcontroller:", value);
}

Writing Data to the Microcontroller

Sending commands or payloads to the device follows an equivalent streaming pattern using port.writable. A TextEncoderStream converts JavaScript strings into raw byte buffers sent over the wire:

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);
const writer = textEncoder.writable.getWriter();

// Send a command to the device
await writer.write("LED_ON\n");

Disconnecting and Port Cleanup

To avoid locking the serial interface on the host operating system, connections must be cleanly terminated. Closing a connection involves canceling active stream readers and writers before closing the port object itself:

await reader.cancel();
await readableStreamClosed.catch(() => {});

await writer.close();
await writableStreamClosed;

await port.close();

By leveraging standard serial communication protocols and modern web stream architectures, the Web Serial API transforms the browser into an interactive environment for hardware control, firmware updates, and real-time telemetry display.