Emerging Interfaces

Talk to an Arduino From a Web Page, With No Driver and No Local Server

The Web Serial API lets a browser open a USB serial port directly. Here is the whole pattern, plus the constraints that decide whether it fits your project.

The standard way to put a sensor on a web page used to involve a local server: a Node or Python process reading the serial port, a WebSocket to the browser, and a README explaining how to start both. It worked, and it meant every person who wanted to try your project had to install something.

Web Serial removes that layer. A web page can open a USB serial port itself. No driver, no local server, no install — a URL, and a permission prompt.

Noah Glaser builds the minimal version: a browser page driving an RGB LED on an Arduino over navigator.serial.

Check support and open a port

if (!('serial' in navigator)) {
  status.textContent = 'Web Serial is not supported in this browser.';
}

Opening requires a user gesture — a real click. You cannot call requestPort() on page load, and you cannot call it from a setTimeout. This is deliberate: it guarantees a human chose to connect a device.

let port;

connectButton.addEventListener('click', async () => {
  port = await navigator.serial.requestPort();
  await port.open({ baudRate: 9600 });
  status.textContent = 'Connected';
  readLoop();
});

requestPort() shows the browser’s device picker. If the user cancels it, it throws — so wrap it in try/catch in anything real, because an uncaught rejection there is the single most common bug in Web Serial demos.

To narrow the picker to boards you recognise:

port = await navigator.serial.requestPort({
  filters: [{ usbVendorId: 0x2341 }]   // Arduino LLC
});

Writing to the board

const encoder = new TextEncoder();

async function send(text) {
  const writer = port.writable.getWriter();
  await writer.write(encoder.encode(text));
  writer.releaseLock();
}

// e.g. set an RGB LED
send('255,0,128\n');

releaseLock() is not optional. A writer holds an exclusive lock on the stream; forget to release it and your next getWriter() throws.

Reading — and the framing mistake

The naive read loop looks like this:

const decoder = new TextDecoder();

async function readLoop() {
  while (port.readable) {
    const reader = port.readable.getReader();
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        console.log(decoder.decode(value));   // ← wrong
      }
    } finally {
      reader.releaseLock();
    }
  }
}

Here is the mistake. Serial is a byte stream with no message boundaries. Each read() returns whatever bytes have arrived — which may be half a line, or two and a half lines. If your Arduino sends println() and you treat each chunk as a reading, you will get garbage the moment your data rate rises above trivial, and it will look intermittent rather than broken, which is much worse.

Buffer and split on your delimiter:

let buffer = '';

async function readLoop() {
  const reader = port.readable.getReader();
  const decoder = new TextDecoder();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      let index;
      while ((index = buffer.indexOf('\n')) >= 0) {
        const line = buffer.slice(0, index).trim();
        buffer = buffer.slice(index + 1);
        if (line) handleLine(line);
      }
    }
  } finally {
    reader.releaseLock();
  }
}

function handleLine(line) {
  const value = Number(line);
  if (!Number.isNaN(value)) meter.value = value;
}

Note { stream: true } on the decode — without it, a multi-byte character split across two chunks decodes to a replacement character.

Closing cleanly, and surviving unplugging

async function disconnect() {
  if (reader) { await reader.cancel(); }
  await port.close();
}

navigator.serial.addEventListener('disconnect', (event) => {
  status.textContent = 'Device unplugged';
});

Handle disconnect. Someone will pull the cable, and a page that silently stops updating is indistinguishable from a page that crashed.

Remembering a device between visits

const ports = await navigator.serial.getPorts();
if (ports.length) {
  await ports[0].open({ baudRate: 9600 });   // no prompt needed
}

Once a user has granted access to a port, getPorts() returns it on later visits with no prompt. This is what makes an installation-grade page practical: plug in, load the page, it connects.

The Arduino side

Keep it text, line-delimited, and simple:

void setup() { Serial.begin(9600); }

void loop() {
  if (Serial.available()) {
    String cmd = Serial.readStringUntil('\n');
    // parse "r,g,b"
  }
  Serial.println(analogRead(A0));
  delay(50);
}

Match baud rates on both sides or you will get plausible-looking nonsense rather than an error.

The constraints that decide whether you can use it

Secure context. HTTPS or localhost. A file:// page will not work.

Chromium only. Chrome, Edge, Opera and the other Chromium browsers support it. Firefox and Safari do not, and Safari’s position means no iOS or iPadOS, in any browser, since they all use WebKit. Android Chrome does support it.

No auto-connect. The first connection always needs a click. For a kiosk or installation, that means either a “connect” screen or Chrome launched with a policy that pre-grants the device.

Permissions Policy in iframes. An embedded page needs allow="serial" on the iframe, and many embedding platforms won’t give you that — which is why a Web Serial demo often has to be its own page rather than a widget.

When this is the right tool

It is the right tool for workshops, documentation, classroom material and one-off control panels — anywhere the install step is the thing standing between someone and your project. Handing over a URL instead of a repo and a toolchain changes how many people actually try it.

It is the wrong tool if you need iOS, Firefox, guaranteed timing, or high throughput — the browser’s scheduling is not a real-time environment, and something latency-critical belongs in native code or on the microcontroller itself.