
A Raw Channel for the Serial Port
Bypass the overhead of native desktop wrappers and talk to your hardware directly using the Web Serial API.
A Raw Channel for the Serial Port
Why are we still shipping 100MB Electron binaries just to send a few bytes of hex to an Arduino or a custom PCB?
For years, the "right" way to talk to hardware from a computer involved a heavy stack: a native language like Python or C++, a specific driver, and usually a bulky desktop wrapper if you wanted a UI. It felt overkill. If I can build a complex 3D engine in a browser, why can't I just talk to the COM port on my desk?
The Web Serial API finally makes this a reality. It's a raw, low-level channel that lets the browser communicate directly with hardware. No middleware, no "bridge" servers, just your code and the silicon.
Breaking the Permission Barrier
The browser is a sandbox for a reason—you don't want a random website scanning your USB devices the moment you land on a homepage. Because of this, everything starts with a user gesture. You can’t just "auto-connect" on page load.
Here is the "Hello World" of getting a port:
// This MUST be called from a button click or user event
async function connectToHardware() {
try {
// Filter for specific hardware if you have the Vendor ID/Product ID
const port = await navigator.serial.requestPort();
console.log("Port selected!", port.getInfo());
return port;
} catch (e) {
console.error("User didn't pick a device or the browser is grumpy.", e);
}
}If you know exactly what hardware you're looking for, you can pass a filter to requestPort. It saves the user from scrolling through a list of their mouse, keyboard, and that weird Bluetooth dongle they forgot about.
Opening the Pipe
Once you have the port object, you haven't actually started talking yet. You need to define the "language" of the connection—the baud rate. If you get this wrong, you'll just get a stream of garbage characters (the classic serial "mojibake").
async function startCommunication(port) {
await port.open({ baudRate: 9600 });
// Common rates: 9600, 115200, 57600
console.log("Communication channel is open.");
}Reading Data (The Stream Approach)
Web Serial doesn't give you a simple "onData" event like some older libraries. It uses the Streams API, which is powerful but a bit more verbose. You have to set up a reader and loop through the incoming chunks.
Since most serial devices send text, you’ll want to pipe the raw bytes through a TextDecoderStream.
async function readContinuously(port) {
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
// Reader has been canceled.
break;
}
// Value is a string thanks to the TextDecoderStream
console.log("Received:", value);
}
} catch (error) {
console.error("Read error:", error);
} finally {
reader.releaseLock();
}
}The Gotcha: Serial data is fragmented. If your device sends "Hello World", you might get "Hel" in the first chunk and "lo World" in the second. If you’re looking for specific commands, you’ll need to buffer the strings until you hit a newline character (\n).
Writing to the Device
Sending data is much simpler, but you still need to convert your strings into a format the hardware understands: Uint8Array.
async function writeToSerial(port, message) {
const encoder = new TextEncoder();
const writer = port.writable.getWriter();
// Add a newline because most serial firmware expects it to trigger an action
const data = encoder.encode(message + "\n");
await writer.write(data);
writer.releaseLock();
console.log("Message sent:", message);
}Handling the "Oops" Moments
Hardware is messy. Users unplug cables. Windows decides to update a driver mid-session. Your code needs to handle the port disappearing gracefully.
The navigator.serial object emits events when a device is plugged in or ripped out:
navigator.serial.addEventListener("connect", (event) => {
console.log("A device was plugged in!");
});
navigator.serial.addEventListener("disconnect", (event) => {
// Logic to update your UI and stop your read loops
console.warn("Device lost. Check the cable.");
});Why Bother?
You might wonder if it’s worth switching from a stable Python script to a browser-based tool.
I found the biggest advantage is zero friction for the end user. If I build a configuration tool for a flight controller or a custom keyboard, I don't want to ask my users to install Python, pip install five packages, and figure out which COM port is which. I want to give them a URL, have them click "Connect," and be done.
Browser Support and Security
As of now, this is primarily a Chromium affair (Chrome, Edge, Opera). Firefox and Safari have been hesitant, citing privacy concerns, though the user-permission model is quite robust.
One final tip: Always close your ports. If you refresh your page while a port is open, the browser might keep a "lock" on that hardware, and the next time you try to connect, you'll get an "Access Denied" error. Clean up after yourself by calling port.close() inside a beforeunload event listener or a cleanup function in your framework of choice.
It’s a raw channel, sure. But it’s the most direct path we’ve ever had between the web and the physical world.


