← Back to DevBytes

Web Serial API: Complete Guide

Introduction to the Web Serial API

The Web Serial API is a powerful browser interface that allows web applications to communicate with serial devices connected to a user's computer. Until recently, interacting with hardware like microcontrollers, sensors, industrial equipment, or 3D printers from a web page required native applications, browser plugins, or workarounds like WebUSB. The Web Serial API bridges this gap by exposing serial port communication directly to JavaScript, enabling true hardware interaction from the browser.

This API is particularly valuable for the growing ecosystem of makers, IoT developers, and industrial engineers who want to build web-based dashboards and configuration tools without forcing users to install dedicated software. In this guide, we'll explore what the Web Serial API is, why it matters, how to use it effectively, and the best practices you should follow when building hardware-enabled web applications.

What Is the Web Serial API?

The Web Serial API provides a low-level interface for reading from and writing to serial ports. A serial port is a bidirectional communication interface that sends and receives data one bit at a time over a single wire (or pair of wires). Common serial protocols include RS-232, TTL-level UART used by microcontrollers like Arduino, and USB-to-serial adapters that expose virtual COM ports.

At its core, the API exposes two main objects: SerialPort, which represents a physical or virtual port, and the navigator.serial interface, which is the entry point for requesting and managing ports. Communication happens through streams — readable and writable streams — which means you can use modern JavaScript stream APIs to process incoming and outgoing data efficiently.

Browser Support and Security Model

The Web Serial API is currently supported in Chromium-based browsers including Chrome, Edge, and Opera. Firefox and Safari have not yet shipped full support, so you should always feature-detect before using the API in production. Because serial communication grants direct hardware access, the API is gated behind a secure context (HTTPS or localhost) and requires explicit user consent. The browser displays a permission prompt listing available ports, and the user must select which port to expose to the page.

Why the Web Serial API Matters

Before the Web Serial API, web developers had limited options for hardware interaction. WebUSB exists, but it targets USB devices directly rather than serial protocols, and many devices only expose virtual serial ports through their USB drivers. Web Bluetooth works for BLE devices but not for classic serial hardware. The Web Serial API fills a critical gap by supporting the vast number of devices that communicate over serial interfaces.

Here are some key reasons this API matters:

Getting Started: Connecting to a Serial Port

Let's walk through the process of connecting to a serial device. The first step is always feature detection, followed by requesting a port from the user, opening it with specific parameters, and then reading and writing data.

Feature Detection

Before attempting to use the Web Serial API, check whether it is available in the current browser:

if ('serial' in navigator) {
  console.log('Web Serial API is supported');
} else {
  console.error('Web Serial API is not supported in this browser');
}

Requesting a Port

Because serial access is a privileged operation, you must request a port in response to a user gesture such as a button click. The browser will show a picker dialog listing available ports:

const connectButton = document.getElementById('connect');

connectButton.addEventListener('click', async () => {
  try {
    const port = await navigator.serial.requestPort();
    await port.open({ baudRate: 9600 });
    console.log('Port opened successfully');
  } catch (error) {
    console.error('Failed to open port:', error);
  }
});

The requestPort() method accepts an optional filter object if you want to restrict the picker to specific USB vendor and product IDs:

const port = await navigator.serial.requestPort({
  filters: [
    { usbVendorId: 0x2341, usbProductId: 0x0043 } // Arduino Uno
  ]
});

Opening the Port

The open() method accepts a configuration object. The only required property is baudRate, but you can also specify data bits, stop bits, parity, and flow control to match your device's settings:

await port.open({
  baudRate: 115200,
  dataBits: 8,
  stopBits: 1,
  parity: 'none',
  flowControl: 'none'
});

Common baud rates include 9600, 19200, 38400, 57600, and 115200. Always check your device's documentation for the correct settings, as mismatched parameters will produce garbled data.

Reading Data from a Serial Port

Once the port is open, you can read data using the port's readable stream. The stream delivers chunks of data as Uint8Array objects. Because serial data arrives in arbitrary chunks, you typically need to buffer and parse it according to your device's protocol.

Basic Reading Loop

Here is a simple reader loop that logs incoming bytes to the console:

async function readFromPort(port) {
  const reader = port.readable.getReader();
  
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        console.log('Reader closed');
        break;
      }
      // value is a Uint8Array
      console.log('Received:', value);
    }
  } catch (error) {
    console.error('Read error:', error);
  } finally {
    reader.releaseLock();
  }
}

Reading Text Lines

Many serial devices send text data terminated by newline characters. The following example buffers incoming bytes and emits complete lines:

async function readLines(port, onLine) {
  const reader = port.readable.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      
      // Keep the last partial line in the buffer
      buffer = lines.pop();

      for (const line of lines) {
        onLine(line.trim());
      }
    }
  } catch (error) {
    console.error('Read error:', error);
  } finally {
    reader.releaseLock();
  }
}

// Usage
readLines(port, (line) => {
  console.log('Line:', line);
});

Using Transform Streams for Parsing

For cleaner architecture, you can pipe the readable stream through a transform stream that handles parsing. This separates concerns and makes your code more reusable:

class LineBreakTransformer {
  constructor() {
    this.chunks = '';
  }

  transform(chunk, controller) {
    this.chunks += new TextDecoder().decode(chunk);
    const lines = this.chunks.split('\n');
    this.chunks = lines.pop();
    lines.forEach(line => controller.enqueue(line));
  }

  flush(controller) {
    if (this.chunks) {
      controller.enqueue(this.chunks);
    }
  }
}

async function readWithTransform(port, onLine) {
  const decoder = new TextDecoderStream();
  const lineStream = new TransformStream(new LineBreakTransformer());

  const readableStreamClosed = port.readable
    .pipeTo(decoder.writable);
  
  const lineReader = decoder.readable
    .pipeThrough(lineStream)
    .getReader();

  try {
    while (true) {
      const { value, done } = await lineReader.read();
      if (done) break;
      onLine(value);
    }
  } catch (error) {
    console.error('Transform read error:', error);
  }
}

Writing Data to a Serial Port

Writing data is straightforward using the port's writable stream. You can send both binary and text data depending on your device's requirements.

Sending Text

async function sendText(port, text) {
  const writer = port.writable.getWriter();
  const encoder = new TextEncoder();
  const data = encoder.encode(text);
  
  await writer.write(data);
  writer.releaseLock();
}

// Send a command followed by a newline
await sendText(port, 'LED ON\n');

Sending Binary Data

async function sendBinary(port, bytes) {
  const writer = port.writable.getWriter();
  await writer.write(new Uint8Array(bytes));
  writer.releaseLock();
}

// Send raw bytes
await sendBinary(port, [0x01, 0x02, 0xFF, 0x00]);

Continuous Writing

If you need to send multiple messages over time, keep the writer open rather than acquiring and releasing it for each write:

const writer = port.writable.getWriter();
const encoder = new TextEncoder();

async function sendCommand(cmd) {
  await writer.write(encoder.encode(cmd + '\n'));
}

// Send several commands
await sendCommand('SET_TEMP 25');
await sendCommand('START');
await sendCommand('STATUS');

// Release when done
writer.releaseLock();

Handling Disconnections and Errors

Serial devices can be disconnected at any time. USB cables get unplugged, devices reset, and power is lost. A robust application must handle these scenarios gracefully. The Web Serial API provides signals for monitoring connection state.

Monitoring Connection Signals

navigator.serial.addEventListener('disconnect', (event) => {
  const disconnectedPort = event.target;
  console.log('Device disconnected:', disconnectedPort);
  // Clean up UI, notify user, attempt reconnection
});

navigator.serial.addEventListener('connect', (event) => {
  const connectedPort = event.target;
  console.log('Device connected:', connectedPort);
  // Optionally offer to reconnect
});

Graceful Error Handling

When a read or write operation fails due to disconnection, the stream will throw an error. You should catch these errors and release locks so the port can be reopened:

async function robustRead(port) {
  while (port.readable) {
    const reader = port.readable.getReader();
    
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        handleData(value);
      }
    } catch (error) {
      console.error('Stream error:', error);
    } finally {
      reader.releaseLock();
    }
  }
  
  console.log('Port is no longer readable');
}

Closing the Port

Always close the port when you are done with it. This releases system resources and allows other applications to access the device:

async function closePort(port) {
  // Release any active readers and writers first
  if (port.readable) {
    const reader = port.readable.getReader();
    reader.cancel();
    await reader.releaseLock();
  }
  
  if (port.writable) {
    const writer = port.writable.getWriter();
    writer.close();
    await writer.releaseLock();
  }
  
  await port.close();
  console.log('Port closed');
}

Remembering and Reconnecting to Ports

A common UX challenge is that the permission prompt requires a user gesture each session. To improve the experience, you can store a reference to a previously granted port using getPorts(), which returns ports the user has already authorized for your origin:

// On page load, check for previously authorized ports
async function autoReconnect() {
  const ports = await navigator.serial.getPorts();
  
  if (ports.length > 0) {
    const port = ports[0];
    try {
      await port.open({ baudRate: 115200 });
      console.log('Reconnected to saved port');
      startReading(port);
    } catch (error) {
      console.error('Could not reopen port:', error);
    }
  }
}

window.addEventListener('DOMContentLoaded', autoReconnect);

Note that getPorts() only returns ports the user has previously selected through requestPort(). It does not bypass the permission model — it simply lets you reopen ports without showing the picker again.

Building a Complete Example: Arduino Monitor

Let's put everything together into a complete example that connects to an Arduino, sends commands, and displays incoming sensor data. This example assumes the Arduino is running a sketch that prints JSON-formatted sensor readings and accepts text commands.

<!DOCTYPE html>
<html>
<head>
  <title>Serial Monitor</title>
</head>
<body>
  <button id="connect">Connect</button>
  <button id="disconnect" disabled>Disconnect</button>
  <input id="command" type="text" placeholder="Enter command" disabled>
  <button id="send" disabled>Send</button>
  <pre id="output"></pre>

  <script>
    let port;
    let reader;
    let writer;
    let keepReading = false;

    const connectBtn = document.getElementById('connect');
    const disconnectBtn = document.getElementById('disconnect');
    const commandInput = document.getElementById('command');
    const sendBtn = document.getElementById('send');
    const output = document.getElementById('output');

    function log(message) {
      output.textContent += message + '\n';
      output.scrollTop = output.scrollHeight;
    }

    async function connect() {
      try {
        port = await navigator.serial.requestPort();
        await port.open({ baudRate: 115200 });
        keepReading = true;
        
        writer = port.writable.getWriter();
        
        connectBtn.disabled = true;
        disconnectBtn.disabled = false;
        commandInput.disabled = false;
        sendBtn.disabled = false;
        
        log('Connected to device');
        readLoop();
      } catch (error) {
        log('Connection failed: ' + error.message);
      }
    }

    async function readLoop() {
      while (port.readable && keepReading) {
        reader = port.readable.getReader();
        const decoder = new TextDecoder();
        let buffer = '';
        
        try {
          while (true) {
            const { value, done } = await reader.read();
            if (done) break;
            
            buffer += decoder.decode(value, { stream: true });
            const lines = buffer.split('\n');
            buffer = lines.pop();
            
            for (const line of lines) {
              if (line.trim()) {
                log('RX: ' + line.trim());
              }
            }
          }
        } catch (error) {
          log('Read error: ' + error.message);
        } finally {
          reader.releaseLock();
        }
      }
    }

    async function disconnect() {
      keepReading = false;
      
      if (reader) {
        await reader.cancel();
      }
      
      if (writer) {
        await writer.releaseLock();
      }
      
      if (port) {
        await port.close();
      }
      
      connectBtn.disabled = false;
      disconnectBtn.disabled = true;
      commandInput.disabled = true;
      sendBtn.disabled = true;
      
      log('Disconnected');
    }

    async function sendCommand() {
      const cmd = commandInput.value;
      if (!cmd || !writer) return;
      
      const encoder = new TextEncoder();
      await writer.write(encoder.encode(cmd + '\n'));
      log('TX: ' + cmd);
      commandInput.value = '';
    }

    connectBtn.addEventListener('click', connect);
    disconnectBtn.addEventListener('click', disconnect);
    sendBtn.addEventListener('click', sendCommand);
    commandInput.addEventListener('keypress', (e) => {
      if (e.key === 'Enter') sendCommand();
    });
  </script>
</body>
</html>

Best Practices

Building reliable serial-enabled web applications requires attention to several important practices. Following these guidelines will help you avoid common pitfalls and deliver a polished user experience.

Always Feature-Detect

Not all browsers support the Web Serial API. Always check for support and provide a clear message or fallback for unsupported browsers:

if (!('serial' in navigator)) {
  showBanner('Your browser does not support the Web Serial API. 
  Please use Chrome or Edge.');
}

Use HTTPS or Localhost

The Web Serial API only works in secure contexts. When deploying your application, ensure it is served over HTTPS. During development, localhost is treated as a secure context, so local testing works without a certificate.

Handle User Gestures Correctly

The requestPort() method must be called from within a user gesture handler. Do not attempt to call it automatically on page load, as the browser will block it. Always tie the connection request to a button click or similar interaction.

Buffer and Parse Carefully

Serial data arrives in arbitrary chunks. Never assume a single read() call returns a complete message. Always buffer incoming data and parse it according to your protocol's framing rules, whether that is newline-delimited text, length-prefixed binary frames, or custom delimiters.

Release Locks Promptly

Every call to getReader() or getWriter() acquires a lock on the stream. You must release these locks with releaseLock() before you can acquire a new reader or writer, or before closing the port. Forgetting to release locks is a common source of errors.

Provide Clear Connection Status

Users need to know whether they are connected, what device they are talking to, and when errors occur. Build a clear status indicator into your UI and surface meaningful error messages rather than raw exception text.

Test with Real Hardware

Serial communication is sensitive to timing, buffer sizes, and device-specific quirks. Always test with the actual hardware you intend to support. If you do not have a physical device handy, you can use a virtual serial port emulator or a USB-to-TTL adapter connected to an Arduino for testing.

Respect the Permission Model

Do not attempt to circumvent the permission prompt. The user's explicit consent is a security feature, not an obstacle. Design your UX so that connecting is a deliberate action, and make it easy to disconnect and revoke access.

Consider Performance for High-Bandwidth Data

If your device sends large amounts of data, avoid processing every byte on the main thread. Consider using Web Workers for parsing, or batch your UI updates using requestAnimationFrame to prevent jank. Transform streams can also help keep your data pipeline efficient.

Conclusion

The Web Serial API opens up an exciting world of hardware interaction directly from the browser. By removing the need for native applications and plugins, it democratizes access to serial devices and makes it easier than ever to build web-based tools for microcontrollers, industrial equipment, and IoT devices. While the API requires careful attention to streaming, error handling, and the permission model, the patterns covered in this guide provide a solid foundation for building robust serial-enabled web applications. As browser support continues to expand, the Web Serial API is poised to become a standard tool in the web developer's toolkit for bridging the gap between the web and the physical world.

— Ad —

Google AdSense will appear here after approval

← Back to all articles