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:
- Zero installation: Users simply visit a webpage, click a button to grant access, and start interacting with hardware. No downloads, no app stores.
- Cross-platform consistency: The same JavaScript code works across Windows, macOS, Linux, Chrome OS, and Android, reducing maintenance costs dramatically.
- Seamless integration with the web ecosystem: You can combine serial data with IndexedDB, Web Workers, WebRTC, or any other web API to build sophisticated dashboards, cloud-connected IoT gateways, or collaborative debugging tools.
- Faster iteration: Hardware developers can prototype and test firmware without leaving the browser, using live editable scripts and immediate visual feedback.
- Accessibility and education: Students and makers can interact with hardware using simple HTML interfaces, lowering the barrier to entry for embedded systems.
Core Concepts and Security Model
Before diving into code, it's crucial to understand how the Web Serial API protects users and devices:
- Secure context required: The API is only available over HTTPS or on
localhost. Insecure HTTP pages cannot access serial ports. - User activation: Calling
navigator.serial.requestPort()must be triggered by a user gesture (click, tap, etc.). The browser presents a chooser dialog where the user selects the device and grants temporary permission. - Permission lifecycle: Once granted, the permission persists for the duration of the page session. To retain access across page reloads, you can save the port object in IndexedDB (more on that later).
- Stream-based model: Communication uses
ReadableStreamandWritableStream, which naturally support backpressure, cancellation, and efficient data handling.
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:
- Always use try/catch around stream operations: Network-like errors (e.g., buffer overruns, USB hiccups) can occur at any time. Wrap reads and writes in robust error handling and implement retry logic where appropriate.
- Manage backpressure properly: If you write faster than the device can consume data, the
write()call will naturally pause (thanks to the streams API). However, monitor your write queue if you have many rapid-fire commands; consider batching or throttling. - Avoid keeping a writer locked indefinitely: For most use cases, acquire a writer for each atomic send operation and release it immediately. This prevents deadlocks and makes it easier to handle concurrent access from multiple UI components.
- Persist port access across page reloads: You can save a port reference to IndexedDB (using the
portobject as a transferable). The next time the page loads, you can callnavigator.serial.getPorts()to retrieve previously authorized ports without a new chooser dialog. This provides a smoother user experience. - Use
getPorts()to reconnect silently: After the initial permission, callingnavigator.serial.getPorts()returns an array of ports the user has granted access to in the past. You can filter by vendor ID or other criteria and open one directly. - Decode text incrementally with
{stream: true}: As shown earlier, always pass this option toTextDecoder.decode()to avoid garbled characters when data arrives in chunks that split multi-byte UTF-8 sequences. - Consider binary protocols carefully: If your device sends binary data (e.g., Modbus RTU, custom sensor frames), parse the
Uint8Arraydirectly instead of decoding to text. UseDataViewor manual buffer stitching to handle frame boundaries. - Protect against partial writes: While the streams API ensures all bytes are written or an error is thrown, you should still design your protocol to be resilient to resets. Include checksums or start/end markers.
- Close the port when done: Always call
port.close()when the user logs out or the session ends. This frees the physical port for other applications and avoids leaving stale OS file handles. - Test with real devices and edge cases: Simulate cable disconnects, sleep/wake cycles, and high-latency environments. The API behaves well but your application logic must be robust.
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:
- WebUSB-based polyfill: If your device uses USB-to-Serial converters, you might use the WebUSB API (also Chromium-only) to emulate serial behavior. This is complex and not a true polyfill.
- Native messaging bridge: You can build a browser extension or a native companion app that acts as a relay, exposing serial data over WebSockets or HTTP. This approach works in any browser but requires additional installation.
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.