← Back to DevBytes

Implementing Web Serial API in Modern Web Applications

What Is the Web Serial API?

The Web Serial API is a browser-based interface that allows web applications to communicate with serial hardware devices directly from JavaScript, without any native extensions or platform-specific plugins. It gives developers access to serial port communication (RS-232, USB-to-Serial converters, Bluetooth SPP, etc.) using standard ReadableStream and WritableStream APIs. This means you can read sensor data, program microcontrollers, control industrial machinery, or interact with IoT devices right from a web page, as long as the user has granted permission and the browser supports it.

The API is part of the larger Web Capabilities project (also known as Project Fugu) and is currently available in Chromium-based browsers, including Google Chrome, Microsoft Edge, Opera, and Samsung Internet on desktop and Android. It bridges the gap between the physical world of hardware and the reach of modern web applications.

Why It Matters for Modern Web Development

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Traditionally, communicating with a serial device required a native desktop or mobile application, often with complex SDKs and platform-specific build pipelines. The Web Serial API removes those barriers entirely. Here's why it's a game changer:

Core Concepts and Security Model

Before diving into code, it's crucial to understand how the Web Serial API protects users and devices:

Getting Started: Feature Detection and Requesting a Port

First, check if the browser supports the Web Serial API by testing for navigator.serial. Then, request a port using a user-initiated event handler. The requestPort() method can be called with optional filters to narrow the device list.

<button id="connect">Connect to Serial Device</button>

<script>
const connectButton = document.getElementById('connect');

connectButton.addEventListener('click', async () => {
  if (!'serial' in navigator) {
    alert('Web Serial API not supported. Use a Chromium-based browser.');
    return;
  }

  try {
    // Request a port. Filters are optional but recommended.
    const port = await navigator.serial.requestPort({
      // Filter by USB vendor and product IDs (optional)
      // filters: [{ usbVendorId: 0x2341, usbProductId: 0x0042 }]
    });
    console.log('Port selected:', port);
    // Store port reference for later use
    window.selectedPort = port;
  } catch (err) {
    console.error('No port selected or user cancelled:', err);
  }
});
</script>

The filter object accepts an array of { usbVendorId, usbProductId } or { bluetoothServiceClassId } entries. This helps the browser show only relevant devices. If you omit filters, all available serial-capable devices are listed. Always handle the case where the user cancels the chooser (the promise rejects with a DOMException).

Opening and Configuring a Serial Port

Once you have a SerialPort object, you need to open it with the desired communication settings. The open() method accepts a SerialOptions dictionary specifying baud rate, parity, data bits, stop bits, and flow control.

async function openPort(port) {
  // Common baud rates: 9600, 115200, 57600, 38400, etc.
  await port.open({
    baudRate: 115200,
    dataBits: 8,       // 7 or 8
    stopBits: 1,       // 1 or 2
    parity: 'none',    // 'none', 'even', 'odd'
    flowControl: 'none', // 'none' or 'hardware' (RTS/CTS)
    bufferSize: 255    // optional: size of internal read/write buffers
  });

  console.log('Port opened successfully');
  // Now you can access port.readable and port.writable
  return port;
}

The baudRate is the only required field. All others default to the most common values (dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none'). After opening, the port exposes two properties: readable (a ReadableStream) and writable (a WritableStream). You'll use these for all data transfer.

Reading Data from a Serial Device

Reading involves obtaining a reader from port.readable, then repeatedly calling read() to pull chunks of data. Each chunk is a Uint8Array (raw bytes). You'll typically decode it with TextDecoder if the device sends text, or process binary data directly.

async function startReading(port) {
  // Create a TextDecoder to interpret bytes as UTF-8 text
  const decoder = new TextDecoder();

  // Get a reader and lock the stream
  const reader = port.readable.getReader();

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        // The stream has been closed (e.g., device disconnected)
        console.log('Readable stream closed');
        reader.releaseLock();
        break;
      }
      // value is a Uint8Array
      const text = decoder.decode(value, { stream: true });
      // Process the received text
      document.getElementById('output').textContent += text;
    }
  } catch (err) {
    console.error('Read error:', err);
    reader.releaseLock();
  }
}

The { stream: true } option in decoder.decode() is important: it tells the decoder that more data may follow, so it won't replace incomplete multibyte characters with replacement characters. This ensures smooth handling of split UTF-8 sequences. The loop runs indefinitely until the stream is closed by the device or you cancel it. Always release the reader lock in a finally block or after catching an error to avoid leaving the stream locked.

Writing Data to a Serial Device

Writing works similarly: obtain a writer from port.writable, encode your text (or binary commands) into a Uint8Array, and pass it to writer.write(). The writer automatically handles backpressure; write() will wait if the buffer is full.

async function sendCommand(port, command) {
  const encoder = new TextEncoder();
  const writer = port.writable.getWriter();

  try {
    // Convert string to Uint8Array
    const data = encoder.encode(command + '\n'); // newline often acts as command delimiter
    await writer.write(data);
    console.log('Command sent:', command);
  } catch (err) {
    console.error('Write error:', err);
  } finally {
    // Always release the writer
    writer.releaseLock();
  }
}

For binary protocols, you can construct Uint8Array manually or use DataView. The releaseLock() is critical; if you forget it, subsequent writes will fail because the stream remains locked. To keep a writer open for continuous communication, you might keep a single writer reference and release it only when the connection is closed.

Handling Disconnection and Cleanup

Serial devices can be physically unplugged at any time. The API provides a disconnect event on the port to gracefully handle this scenario. You should also properly close the port when the user wants to disconnect, and clean up reader/writer locks.

async function monitorDisconnection(port) {
  port.addEventListener('disconnect', (event) => {
    console.log('Device disconnected:', event.target);
    // Clean up UI, stop reading/writing
    // The port is now unusable
    const statusEl = document.getElementById('status');
    statusEl.textContent = 'Device disconnected';
    statusEl.style.color = 'red';
  });
}

async function closePort(port) {
  // If a reader is active, cancel it to release lock
  if (port.readable) {
    const reader = port.readable.getReader();
    await reader.cancel(); // forces the read loop to exit
    reader.releaseLock();
  }

  // Close the port (this also closes the underlying streams)
  await port.close();
  console.log('Port closed');
}

Calling port.close() closes both readable and writable streams, releases all locks, and renders the port object unusable until reopened. The disconnect event fires when the physical device is detached or the operating system reports disconnection. After this event, the port is automatically closed, but it's still good practice to handle any remaining UI state.

Building a Complete Example: Serial Terminal in the Browser

Let's combine everything into a minimal but functional serial terminal. This HTML page lets you connect to a serial device, see incoming data in a <pre> block, and send text from an input field.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Web Serial Terminal</title>
  <style>
    body { font-family: monospace; padding: 1em; }
    #output { background: #f4f4f4; border: 1px solid #ccc; height: 300px; overflow-y: auto; white-space: pre-wrap; padding: 10px; }
    .toolbar { margin-bottom: 1em; }
  </style>
</head>
<body>
  <h2>Serial Terminal</h2>
  <div class="toolbar">
    <button id="connectBtn">Connect</button>
    <button id="disconnectBtn" disabled>Disconnect</button>
    <span id="status">Not connected</span>
  </div>
  <pre id="output">Awaiting data...</pre>
  <div>
    <input type="text" id="input" placeholder="Type command..." disabled>
    <button id="sendBtn" disabled>Send</button>
  </div>

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

    const output = document.getElementById('output');
    const input = document.getElementById('input');
    const connectBtn = document.getElementById('connectBtn');
    const disconnectBtn = document.getElementById('disconnectBtn');
    const sendBtn = document.getElementById('sendBtn');
    const status = document.getElementById('status');

    // Connect flow
    connectBtn.addEventListener('click', async () => {
      if (!'serial' in navigator) {
        status.textContent = 'Web Serial API not supported.';
        return;
      }
      try {
        port = await navigator.serial.requestPort();
        await port.open({ baudRate: 115200 });
        status.textContent = 'Connected';
        status.style.color = 'green';
        toggleUI(true);

        // Start reading
        keepReading = true;
        readLoop();

        // Listen for disconnection
        port.addEventListener('disconnect', () => {
          status.textContent = 'Device disconnected';
          status.style.color = 'red';
          toggleUI(false);
          keepReading = false;
          port = null;
        });
      } catch (err) {
        status.textContent = `Error: ${err.message}`;
      }
    });

    async function readLoop() {
      const decoder = new TextDecoder();
      while (keepReading && port?.readable) {
        try {
          reader = port.readable.getReader();
          while (true) {
            const { value, done } = await reader.read();
            if (done) break;
            output.textContent += decoder.decode(value, { stream: true });
            output.scrollTop = output.scrollHeight;
          }
        } catch (err) {
          console.error('Read error:', err);
        } finally {
          if (reader) {
            reader.releaseLock();
            reader = null;
          }
        }
      }
    }

    // Sending data
    sendBtn.addEventListener('click', async () => {
      const cmd = input.value;
      if (!cmd || !port) return;
      const encoder = new TextEncoder();
      try {
        writer = port.writable.getWriter();
        await writer.write(encoder.encode(cmd + '\n'));
        input.value = '';
      } catch (err) {
        console.error('Write error:', err);
      } finally {
        if (writer) {
          writer.releaseLock();
          writer = null;
        }
      }
    });

    // Disconnect
    disconnectBtn.addEventListener('click', async () => {
      keepReading = false;
      if (reader) {
        await reader.cancel();
        reader.releaseLock();
        reader = null;
      }
      if (writer) {
        writer.releaseLock();
        writer = null;
      }
      if (port) {
        await port.close();
        port = null;
      }
      status.textContent = 'Disconnected';
      status.style.color = 'black';
      toggleUI(false);
    });

    function toggleUI(connected) {
      connectBtn.disabled = connected;
      disconnectBtn.disabled = !connected;
      input.disabled = !connected;
      sendBtn.disabled = !connected;
    }
  </script>
</body>
</html>

This example demonstrates a full round-trip: requesting a port, opening it, reading asynchronously, writing on demand, handling disconnection, and cleaning up resources. Note the keepReading flag to break out of the reading loop gracefully when the user clicks Disconnect. Also, the reading loop re-acquires a reader inside the outer while loop—this is intentional to handle potential reconnects or recovery, though in a production app you might structure it differently.

Best Practices for Production-Ready Serial Communication

Moving from a proof-of-concept to a reliable web application requires attention to detail. Here are the most important best practices:

Browser Support and Polyfills

As of now, the Web Serial API is only available in Chromium-based browsers (Chrome 89+, Edge 89+, Opera 76+, Samsung Internet 15+). Firefox and Safari have not implemented it yet, and there is no official timeline. However, there are some partial polyfills:

For projects targeting a wide audience, implement feature detection and gracefully degrade the experience, showing an informative message when the API is missing.

Conclusion

The Web Serial API brings hardware communication into the open, accessible world of the web. By leveraging streams-based I/O, strong security constraints, and a user-friendly permission model, it empowers developers to build powerful IoT dashboards, firmware flashers, CNC controllers, and educational tools without leaving the browser. The patterns shown here—requesting ports, opening with proper configuration, reading and writing using ReadableStream and WritableStream, handling disconnections, and following best practices—will serve as a solid foundation for any serial-connected web application. As browser support evolves and the API becomes more widespread, mastering these techniques will position you at the forefront of modern web–hardware integration.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles