- We will build a React app that connects directly to a Bluetooth Heart Rate strap.
- No backend required: we use the browser's Web Bluetooth API.
- You'll learn how to parse binary data streams using
DataView. - We'll handle connection states and permissions gracefully.
Introduction
For years, connecting hardware devices to web applications required complex native wrappers, specialized plugins, or a backend server acting as a bridge. Enter the Web Bluetooth API.
It allows your browser (specifically Chrome, Edge, and Opera) to communicate directly with Bluetooth Low Energy (BLE) devices. This opens up a world of possibilities for HealthTech, IoT, and educational tools running entirely on the client side.
In this tutorial, we’re going to build a Real-Time Heart Rate Monitor. We will connect a standard BLE heart rate strap (like a Polar H10 or a generic monitor) to a React application, decode the binary data, and visualize your pulse.
Understanding the Problem
How BLE Works (The 30-Second Version)
Bluetooth Low Energy devices organize data into Services and Characteristics (this structure is called GATT).
- Server: The device (Heart Rate Strap).
- Service: A collection of data (e.g., Heart Rate Service: UUID
0x180D). - Characteristic: The actual data point (e.g., Heart Rate Measurement: UUID
0x2A37).
The Challenge: The data coming from the device isn't JSON. It's a raw binary stream. We have to request permission, connect, listen for changes, and bit-mask the bytes to get human-readable numbers.
Prerequisites
- A basic understanding of React (Hooks).
- A Bluetooth Low Energy Heart Rate Monitor (or an emulator app like "nRF Connect" on your phone).
- A computer with Bluetooth support.
- Chrome or Edge browser (Firefox and Safari support is currently limited).
Step 1: Setting Up the Project
Let's initialize a simple React project. We'll use Vite for speed, but CRA works too.
npm create vite@latest heart-rate-monitor -- --template react
cd heart-rate-monitor
npm install
npm run dev
We don't need external libraries for Bluetooth—it's native to the browser! However, we will use standard React hooks to manage our interface.
Step 2: Connection Logic
Security involves a "User Gesture." You cannot scan for Bluetooth devices automatically on page load; the user must click a button.
Create a file src/components/HeartRateMonitor.jsx:
import React, { useState, useRef } from 'react';
const HeartRateMonitor = () => {
const [heartRate, setHeartRate] = useState(null);
const [device, setDevice] = useState(null);
const [status, setStatus] = useState('Disconnected');
const [error, setError] = useState('');
// We keep a ref to the device to handle disconnection cleanup
const deviceRef = useRef(null);
const connectToDevice = async () => {
try {
setStatus('Requesting device...');
// 1. Request the device
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }], // Standard HR Service UUID
optionalServices: ['battery_service'] // Just in case we want battery later
});
deviceRef.current = device;
setDevice(device);
// 2. Connect to the GATT server
setStatus('Connecting to GATT Server...');
const server = await device.gatt.connect();
// 3. Get the Heart Rate Service
const service = await server.getPrimaryService('heart_rate');
// 4. Get the Characteristic
const characteristic = await service.getCharacteristic('heart_rate_measurement');
// 5. Subscribe to notifications
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', handleHeartRateChange);
// Handle disconnection
device.addEventListener('gattserverdisconnected', onDisconnected);
setStatus('Connected');
setError('');
} catch (err) {
console.error(err);
setError(err.message);
setStatus('Error');
}
};
const onDisconnected = () => {
setStatus('Disconnected');
setHeartRate(null);
setDevice(null);
};
const disconnectFromDevice = () => {
if (deviceRef.current) {
deviceRef.current.gatt.disconnect();
}
};
// Placeholder for parsing logic (Step 3)
const handleHeartRateChange = (event) => {
console.log("Data received", event.target.value);
};
return (
<div className="monitor-container">
<h1>Web Bluetooth HR Monitor</h1>
{error && <p className="error">{error}</p>}
<p>Status: {status}</p>
{!device ? (
<button onClick={connectToDevice}>Connect to Heart Rate Monitor</button>
) : (
<button onClick={disconnectFromDevice}>Disconnect</button>
)}
{heartRate && <div className="heart-rate">{heartRate} BPM</div>}
</div>
);
};
export default HeartRateMonitor;
How it works
navigator.bluetooth.requestDevice: Triggers the browser's native popup. We filter byheart_ratebecause we only want relevant devices.gatt.connect(): Establishes the link.startNotifications(): Tells the device "Hey, send me data whenever the heart rate changes" (usually once per second).
Step 3: Parsing Binary Data
This is the most critical part. The Bluetooth standard for Heart Rate (UUID 0x2A37) sends a stream of bytes. We need to parse them.
The first byte is the Flags byte.
- Bit 0: Heart Rate Value Format bit.
0= Heart Rate is UINT8 (0-255 bpm).1= Heart Rate is UINT16 (0-65535 bpm).
Update the handleHeartRateChange function in your component:
const handleHeartRateChange = (event) => {
const value = event.target.value;
// value is a DataView object
// 1. Parse the flags (first byte)
const flags = value.getUint8(0);
// 2. Determine format based on Bit 0
// (flags & 1) checks if the first bit is 1
const rate16Bits = (flags & 0x1) === 1;
let heartRateMeasurement;
if (rate16Bits) {
// 16-bit heart rate (offset 1)
heartRateMeasurement = value.getUint16(1, true /* littleEndian */);
} else {
// 8-bit heart rate (offset 1)
heartRateMeasurement = value.getUint8(1);
}
setHeartRate(heartRateMeasurement);
};
Why do we need this?
Most consumer straps use 8-bit (uint8). However, medical-grade devices or specialized equipment might use 16-bit. If you just read the byte at index 1 without checking the flag, your app will break on high-end devices.
Step 4: Adding Visual Polish
Let's make it look like a real health app. Add a CSS pulse effect based on the connection status.
App.css:
.monitor-container {
display: flex;
flex-direction: column;
align-items: center;
font-family: sans-serif;
margin-top: 50px;
}
.heart-rate {
font-size: 6rem;
font-weight: bold;
color: #e74c3c;
margin: 20px 0;
animation: pulse 1s infinite;
}
button {
padding: 10px 20px;
font-size: 1rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background-color: #2980b9;
}
.error {
color: red;
background: #fee;
padding: 10px;
border-radius: 4px;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
Putting It All Together
Here is the complete, integrated component.
// src/components/HeartRateMonitor.jsx
import React, { useState, useRef, useEffect } from 'react';
import './App.css'; // Assuming you put styles here
const HeartRateMonitor = () => {
const [heartRate, setHeartRate] = useState(null);
const [device, setDevice] = useState(null);
const [status, setStatus] = useState('Disconnected');
const [error, setError] = useState('');
const deviceRef = useRef(null);
// Clean up on unmount
useEffect(() => {
return () => {
if (deviceRef.current) {
deviceRef.current.gatt.disconnect();
}
};
}, []);
const connectToDevice = async () => {
try {
setError('');
setStatus('Searching...');
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }]
});
deviceRef.current = device;
setDevice(device);
device.addEventListener('gattserverdisconnected', onDisconnected);
setStatus('Connecting...');
const server = await device.gatt.connect();
const service = await server.getPrimaryService('heart_rate');
const characteristic = await service.getCharacteristic('heart_rate_measurement');
await characteristic.startNotifications();
characteristic.addEventListener('characteristicvaluechanged', handleHeartRateChange);
setStatus('Connected');
} catch (err) {
console.error(err);
setStatus('Disconnected');
// Ignore "User cancelled" errors
if (err.name !== 'NotFoundError') {
setError('Failed to connect: ' + err.message);
}
}
};
const onDisconnected = () => {
setStatus('Disconnected');
setHeartRate(null);
setDevice(null);
// Optional: Auto-reconnect logic could go here
};
const handleHeartRateChange = (event) => {
const value = event.target.value;
const flags = value.getUint8(0);
// Check Least Significant Bit for format
const rate16Bits = (flags & 0x1) === 1;
let heartRateMeasurement;
if (rate16Bits) {
heartRateMeasurement = value.getUint16(1, true);
} else {
heartRateMeasurement = value.getUint8(1);
}
setHeartRate(heartRateMeasurement);
};
const disconnect = () => {
if (deviceRef.current) {
deviceRef.current.gatt.disconnect();
}
};
return (
<div className="monitor-container">
<h1>❤️ React HR Monitor</h1>
<div className="status-indicator">
Status: <strong>{status}</strong>
</div>
{error && <div className="error">{error}</div>}
{heartRate !== null && (
<div className="heart-rate">
{heartRate} <span style={{fontSize: '2rem'}}>BPM</span>
</div>
)}
<div className="controls">
{!device ? (
<button onClick={connectToDevice}>Scan for Device</button>
) : (
<button onClick={disconnect} style={{backgroundColor: '#e74c3c'}}>
Disconnect
</button>
)}
</div>
{!navigator.bluetooth && (
<p className="warning">
⚠️ Your browser does not support Web Bluetooth. Try Chrome or Edge.
</p>
)}
</div>
);
};
export default HeartRateMonitor;
Common Pitfalls & Security
- HTTPS is Mandatory: Web Bluetooth only works on secure contexts (HTTPS) or
localhost. You cannot deploy this to an HTTP site. - User Gesture: You cannot call
requestDeviceinside auseEffecton load. It must be triggered by a click event. - Browser Support: Currently, this works best on Chrome, Edge, and Android (Chrome). iOS Safari does not support Web Bluetooth.
- Workaround for iOS: Use the "Bluefy" Web BLE browser app to test your site on an iPhone.
- Device Compatibility: Ensure your BLE device is not currently connected to your phone's native system menu. BLE devices can usually only maintain one connection at a time.
Conclusion
You’ve just built a bridge between the physical world (your heart) and the digital world (React), without a single line of backend code.
This pattern can be extended to control lights, read thermometers, or interact with educational robots. The Web Bluetooth API turns the browser into a powerful hardware interface tool.
Next Steps for You:
- Try adding a real-time chart using
rechartsto visualize the heart rate over time. - Read the "Energy Expended" field from the Bluetooth packet (it's often in the same data stream!).
- Experiment with the Battery Service to show the device's battery level.
Resources
- MDN Web Bluetooth API Documentation
- Bluetooth GATT Specifications
- Google Chrome Web Bluetooth Samples
Discussion Questions
- Have you ever used the Web Bluetooth API for a project? What was the hardest part?
- What security concerns do you have regarding websites connecting to local hardware?
- If you could connect any device to a web page, what would you build?