Reading Temperature and Humidity via Arduino in the Browser - Web Serial API
What is the Serial API?
Google Chrome 89 introduced the Web Serial API, allowing external devices to interact directly via browser APIs, including USB devices or Bluetooth devices with a Serial interface. This allows browsers to communicate directly with hardware.
In the past, building similar applications required writing a separate server to interface with data sent over the serial port, which was then forwarded to the frontend via an API or WebSocket. However, this approach had several drawbacks:
- Requires a server to act as middleware
- Potential latency during data transmission (data sent to computer → received by server → sent to client via socket)
- May require installing additional drivers
The biggest benefit of the Web Serial API for users is that it allows connecting external devices directly to web pages via Serial.
Introduction to the Web Serial API
The Serial API can be accessed via navigator.serial. Currently, it is only implemented in Chrome 89, and other browsers do not yet support this API.
The main flow of connecting to a SerialPort can be divided into:

Serial.requestPort(options): The user selects the corresponding serial port- Returns a
SerialPortobject - Calls the
SerialPort.open()method - Uses
SerialPort.readable.getReader()to retrieve data
1. Serial.requestPort(options)
When calling this method, note that it must be triggered by a user gesture (such as a click or keypress event). It returns a promise containing the port object.
navigator.serial.requestPort()
.then(port => {
})
If called directly without user interaction, the promise will be rejected immediately:
This function must be called during a user gesture
2. Communicating with Serial via the SerialPort Object
Before communicating with Serial, you must open the serial port and determine the baud rate. Why do you need to determine the baud rate first? Because in Serial communication, both sides do not share a common clock, a baud rate must be agreed upon in advance to decode the data correctly.
If you want to learn more about how Serial communication works, you can check out the article I wrote when implementing an Arduino CO2 concentration monitor, which explains the basic principles of Serial communication.
To allow the browser to communicate with the serial port, you can call the SerialPort.open() method, which accepts configuration options:
const port = await serial.requestPort();
await port.open({ baudRate: 9600 });
After this, you can start listening to data transmitted from the serial port.
3. Calling readable.getReader() to Retrieve Serial Data
To listen to data sent over Serial, you can read it using SerialPort.readable.getReader(). One of the properties of SerialPort is readable, which is a ReadableStream. It allows communication with other stream-enabled objects through the getReader() API.
Once you have the reader, you can call reader.read() to retrieve data. This API returns value and done to indicate whether the data reading is complete and to provide the transmitted value. The data returned by Serial is always represented as a Uint8Array, meaning you need to use DataView or other methods to parse it.
4. Closing the SerialPort
You can call port.close() on the SerialPort to terminate communication. However, this method can only be executed successfully if both readable and writable streams in Serial are unlocked.
To ensure there is no ongoing data transmission, you can use reader.cancel() to force-cancel any active transfer, which sets done returned by reader.read() to true. Finally, release the lock via reader.releaseLock() and call port.close() to close the connection.
5. Listening to USB Connect and Disconnect Events
You can listen for connect and disconnect events on navigator.serial to detect whether a USB device is plugged in or removed, and update the UI accordingly.
navigator.serial.addEventListener('connect', () => {});
navigator.serial.addEventListener('disconnect', () => {});
Reading Temperature and Humidity with the Web Serial API
Now that we understand the interface and working principles of the Web Serial API, let’s try using it to connect to temperature and humidity data from an Arduino and implement a simple component to display on a webpage.
Prerequisites
- Chrome 89 (other browsers do not currently support the Web Serial API)
- DHT11 (DHT22 also works)
- Arduino Nano (any other Arduino board will also work)
1. Preparing the Arduino Circuit
This article won’t go into too much detail about the Arduino circuitry. Here, we connect the temperature sensor to the Arduino to read temperature and humidity, and then the Arduino sends the data to the computer via Serial. The code looks like this:
#include <dht.h>
dht DHT;
#define DHT11_PIN 7
void setup(){
// Set baud rate to 9600
Serial.begin(9600);
}
void loop(){
DHT.read11(DHT11_PIN);
sprintf(output, "{ \"temperature\": %.2f, \"humidity\": %.2f }", DHT.temperature, DHT.humidity);
Serial.write(output);
delay(1000);
}
The DHT library used here can be found on GitHub.
Calling the DHT.read11 method stores the temperature and humidity data in DHT.temperature and DHT.humidity, and Serial.write is then used to send the data to the SerialPort. Here, the data is formatted as JSON (which makes parsing easier).
2. Sending Data to Serial
Serial.write sends data to the SerialPort. Remember to configure Serial.begin(9600) so that the Arduino knows to transmit data at a rate of 9600 bit/s.
If you just want to test reading Serial data, you don’t actually need a sensor; calling the Serial.write API directly is sufficient.
3. Receiving Data in the Browser
The implementation is very similar to the flow described earlier in this article. Here, we use TextDecoderStream to help decode the stream (since we are sending strings).
async function requestSerialPort() {
const serial = navigator.serial;
// Select target Serial Port
const port = await serial.requestPort();
// Set baud rate to 9600
await port.open({ baudRate: 9600 });
// Decode bit data into text
let decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();
try {
let buffer = '';
const timerId = setInterval(async () => {
const { value, done } = await reader.read();
buffer += value;
if (buffer.includes('{') && buffer.includes('}')) {
const start = buffer.indexOf('{');
const end = buffer.indexOf('}');
buffer = buffer.slice(start, end + 1);
try {
const { temperature, humidity } = JSON.parse(buffer);
console.log(temperature, humidity);
} catch (err) {
console.log(err);
}
buffer = '';
}
}, 1500);
} catch (err) {
console.log(err);
}
};
The code uses a buffer variable to store the incoming string (this simple implementation has its edge-case issues, but it works fine for demonstration purposes). This is because the packet size in Serial transmission is around 1 byte (depending on data frames and stop bits), so we need a variable as a temporary buffer before the entire payload (JSON) is fully transmitted.
4. UI Implementation (Using Svelte as an Example)
For the UI, a Gauge component was implemented using d3-shape and d3-scale to make it look less plain. Although the code is written in Svelte, it can easily be achieved with other frontend frameworks or even vanilla JavaScript:
<script>
import { tweened } from 'svelte/motion'
import { arc } from 'd3-shape';
import { scaleLinear } from 'd3-scale';
export let maxValue;
export let minValue;
export let value;
export let unit;
export let label;
export let toFixed;
export let fillColor;
let indicator = tweened(0)
$: scale = scaleLinear()
.domain([minValue || 0, maxValue])
.range([0, 1]);
$: precentage = scale(value);
$: angleScale = scaleLinear()
.domain([0, 1])
.range([-Math.PI / 2, Math.PI / 2])
.clamp(true);
$: angle = angleScale(precentage);
let backgroundArc = arc()
.innerRadius(0.75)
.outerRadius(1)
.startAngle(-Math.PI / 2)
.endAngle(Math.PI / 2)
.cornerRadius(1);
$: filledArc = arc()
.innerRadius(0.75)
.outerRadius(1)
.startAngle(-Math.PI / 2)
.endAngle(angle)
.cornerRadius(1);
$: {
indicator.set(angle)
}
</script>
<div class="gauge">
<svg viewBox="-1 -1 2 1" class="circle">
<path d={backgroundArc()} fill="#aaa" />
<path d={filledArc()} fill={fillColor} />
</svg>
<svg style={`transform: translateX(-50%) rotate(${$indicator}rad);`} class="dial" width="9" height="23" viewBox="0 0 9 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 0L8.02368 18.5089C8.5256 20.8178 6.76678 23 4.40402 23V23C2.10447 23 0.360582 20.9267 0.754589 18.6611L4 0Z" fill="#C4C4C4"/>
</svg>
<span class="label">
<span class="labelName">{label}</span>
<div>
<span class="value" style="color: {fillColor}"> {toFixed ? value.toFixed(toFixed) : Math.floor(value)} <small>{unit}</small></span>
</div>
</span>
</div>
Defining Scales
First, define scale. We want to map data values to 0 and 1 for easier calculations. Next, define angleScale to map 0 and 1 to -90° ~ 90° (-π/2 to π/2 radians).
arc
Using the d3-shape arc API, it generates the corresponding SVG path based on the given inner/outer radius, start angle (startAngle), and end angle (endAngle), making it convenient to embed directly into the SVG.
Defining viewBox
Unlike typical Cartesian coordinate systems, an SVG’s origin is at the top-left corner. In order to center our Gauge component, we need to configure viewBox with an offset. You can think of the viewBox as a viewport: the first two values represent the origin position, and the last two represent the width and height. Therefore, viewBox="10 10 5 5" means using (10, 10) as the origin to create a visible window with a width of 5 and a height of 5.
Because our Gauge component has no inherent offset, the center of the circle sits at the origin (0, 0). If we draw a circle with a radius of 1, it will fall outside the visible area as shown below, and nothing will be visible.

To make the entire Gauge visible within the viewport, we need to shift the origin to (-1, -1). The following values, 2 and 1, set the width and height to 2 and 1, perfectly filling the entire visible area.

5. Results
Combine everything together, and you will see the results! The complete source code can be found on GitHub.
Conclusion
As you can see, web technologies have recently been extending gradually into hardware applications. In addition to the Serial API, there are also the NFC API and HID API. As applications become richer, it also means system architectures become increasingly complex. Receiving sensor data and displaying it on a screen is just one of many use cases.
These standards are still in working draft stages, and currently Chrome seems to be the only browser actively implementing them. You may not need to invest heavy effort into studying them just yet, but it signals that the domain for frontend engineers—ranging from building UIs, managing data flows, and handling interactions—is expanding toward hardware integration. In the future, frontend engineers will have yet another path to explore.
References
Related Posts
- Recreating My Room with Three.js Using React Three Fiber, I brought my real room into the browser—turning physical objects into an interactive table of contents, and using spatial memory to tell the story of my life and work over the past few years.
- Things to Keep in Mind When Using Images in Frontend Development Expanding on Jake Archibald's article, this post organizes how modern responsive images should be written: why width/height are still necessary, when to use CSS aspect-ratio, how to choose between AVIF and WebP, and using picture/source/srcset for art direction on mobile devices.
- CSS field-sizing — Auto-resize Form Elements with a Single Line of CSS Previously, auto-resizing a textarea required listening to scrollHeight in JavaScript. With CSS field-sizing: content, a single line replaces it all, supporting textarea, input, and select. This article covers the pain points of older approaches and how to use field-sizing.
- Make Your Link Underlines Look Better: text-underline-offset By default, underlines sit very close to the text. Some designers dislike this look, and personally, I don't think it looks great either.