---
title: "Recipe: Parse a BLE Heart Rate Measurement (0x2A37)"
canonical: "https://aifitnessapi.com/cookbook/ble-heart-rate-parser"
cluster: "Cookbook"
primary_query: "parse bluetooth heart rate measurement 0x2a37 javascript"
last_reviewed: "2026-11-05"
description: "Tested JavaScript that decodes Bluetooth Heart Rate Measurement notifications: bpm, sensor contact, energy and RR intervals, per the SIG's structure."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Recipe: Parse a BLE Heart Rate Measurement (0x2A37)\", AIFitnessAPI, https://aifitnessapi.com/cookbook/ble-heart-rate-parser"
---

# Recipe: Parse a BLE Heart Rate Measurement (0x2A37)

> A dependency-free JavaScript parser for the Bluetooth Heart Rate Measurement characteristic (0x2A37) that turns a notification into heart rate, sensor contact, energy expended and RR intervals, and throws on a value the flags do not account for. Bit 0 of the Flags octet picks a uint8 or uint16 heart rate, bits 1 and 2 report contact, bit 3 announces a uint16 Energy Expended and bit 4 RR intervals in units of 1/1024 second, all little-endian. Its tests check those bit positions against the Bluetooth SIG structure the site generates and run the parser against the site's structure-driven decoder for every flag combination.

- Canonical: https://aifitnessapi.com/cookbook/ble-heart-rate-parser
- Last reviewed: 2026-11-05
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Recipe: Parse a BLE Heart Rate Measurement (0x2A37)", AIFitnessAPI, https://aifitnessapi.com/cookbook/ble-heart-rate-parser

---

Every heart-rate strap and most watches broadcast the same characteristic, and almost every hand-written parser for it is wrong in one of three ways: it ignores bit 0 and reads a 16-bit heart rate as 8, it collapses sensor contact to a boolean, or it reads RR intervals as milliseconds. The site's [heart-rate lab](/tools/heart-rate-lab) decodes 0x2A37 live in the browser from the Bluetooth SIG's structure; this recipe is a short, fixed-layout parser for your own app, held to that decoder by its tests.

## The layout, from the SIG's structure

| Octet | Field | Present when |
|---|---|---|
| 0 | Flags | always |
| 1 or 1-2 | Heart Rate Measurement Value, uint8 or uint16, beats per minute | bit 0 is 0 (uint8) or 1 (uint16) |
| next 2 | Energy Expended, uint16, SIG unit joule | bit 3 is 1 |
| rest | RR-Interval, uint16 each, 1/1024 s | bit 4 is 1 |

Bit 1 is "Sensor Contact detected", bit 2 "Sensor Contact Supported", bits 5-7 are reserved. The SIG's YAML does not state byte order; the Web Bluetooth specification's example reads every uint16 little-endian, and so does this parser. Each line is from the site's generated copy of the SIG's GATT Specification Supplement in the [Bluetooth fitness reference](/bluetooth-fitness).

## What the recipe does

- **Accepts what platforms hand you**: a `DataView` from Web Bluetooth, a `Buffer`, a `Uint8Array`, an `ArrayBuffer` or a byte array.
- **Decodes every defined field** and returns RR intervals raw and in milliseconds.
- **Keeps contact three-valued** and reports reserved bits instead of dropping them.
- **Throws on a value the flags do not explain**: too short for a flagged field, RR flagged with no RR octets, an odd RR tail, or octets left over.
- **Wires into Web Bluetooth** with `onHeartRate`, which routes parse errors to a callback instead of crashing the listener.

## The implementation

The canonical file is `cookbook/ble-heart-rate-parser.mjs` in this site's repository, and `cookbook/ble-heart-rate-parser.test.mjs` runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies and runs in Node 20 or newer or in any browser.

```js
/**
 * ble-heart-rate-parser.mjs — decode a Bluetooth Heart Rate Measurement
 * (0x2A37) notification into beats per minute, sensor contact, energy and
 * RR intervals, refusing a malformed value instead of guessing.
 *
 * WHAT THIS IMPLEMENTS
 *   `parseHeartRateMeasurement(bytes)` for a Uint8Array, Buffer, DataView or
 *   ArrayBuffer — what Web Bluetooth's characteristicvaluechanged, a native
 *   BLE library or a captured log hands you. The layout is the Bluetooth
 *   SIG's GATT Specification Supplement structure, as the site's generated
 *   copy holds it (src/data/bluetoothGatt.ts):
 *     octet 0       Flags
 *       bit 0       0 = Heart Rate Value Format uint8, 1 = uint16
 *       bit 1       Sensor Contact detected
 *       bit 2       Sensor Contact Supported
 *       bit 3       Energy Expended present (uint16; SIG unit: joule)
 *       bit 4       RR-Interval present (uint16 each, "1/1024 second as the
 *                   unit"; as many as fit after the fields before them)
 *       bits 5-7    Reserved for Future Use
 *   Multi-octet fields are little-endian, as the Web Bluetooth
 *   specification's own heart-rate example reads them.
 *
 *   Sensor contact is three-valued on purpose: when bit 2 is clear the
 *   device does not report contact, which is not the same as "no contact".
 *   RR intervals come back raw and in milliseconds; HRV needs them, and a
 *   device that never sets bit 4 cannot give you HRV at all.
 *
 *   The Energy Expended value is returned raw with the SIG's unit (joule).
 *   The Web Bluetooth example's comment reads its 750 as "750kJ"; this file
 *   does not pick between the two, it reports what the structure says.
 *
 * PAGES THIS SUPPORTS
 *   https://aifitnessapi.com/tools/heart-rate-lab (the same structure, live)
 *   https://aifitnessapi.com/bluetooth-fitness
 *
 * No dependencies. Node 20+ or any browser.
 *
 * MIT — from aifitnessapi.com/cookbook
 */

export const HEART_RATE_MEASUREMENT_UUID = 0x2a37;
export const RR_UNITS_PER_SECOND = 1024;

export class HeartRateParseError extends Error {
  constructor(message, bytes) {
    super(message);
    this.name = "HeartRateParseError";
    this.bytes = bytes;
  }
}

function asBytes(input) {
  if (input instanceof Uint8Array) return input;
  if (input instanceof ArrayBuffer) return new Uint8Array(input);
  if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
  if (Array.isArray(input)) return Uint8Array.from(input);
  throw new TypeError("expected bytes: Uint8Array, Buffer, DataView, ArrayBuffer or number[]");
}

const u16 = (b, i) => b[i] | (b[i + 1] << 8);

/**
 * @returns {{ heartRate: number, format: "uint8"|"uint16",
 *            sensorContact: "detected"|"not detected"|"not supported",
 *            energyExpended: number|null, energyUnit: "joule (SIG unit)"|null,
 *            rrIntervals: number[], rrMs: number[], rrPresent: boolean,
 *            reservedBits: number[] }}
 */
export function parseHeartRateMeasurement(input) {
  const b = asBytes(input);
  if (b.length < 2) throw new HeartRateParseError(`a 0x2A37 value is at least 2 octets; got ${b.length}`, b);
  const flags = b[0];
  const hr16 = (flags & 0x01) !== 0;
  const contactDetected = (flags & 0x02) !== 0;
  const contactSupported = (flags & 0x04) !== 0;
  const energyPresent = (flags & 0x08) !== 0;
  const rrPresent = (flags & 0x10) !== 0;
  const reservedBits = [5, 6, 7].filter((bit) => (flags >> bit) & 1);

  let off = 1;
  const need = (n, what) => {
    if (off + n > b.length) throw new HeartRateParseError(`${what} is flagged present but the value ends at octet ${b.length}`, b);
  };
  need(hr16 ? 2 : 1, "the heart-rate value");
  const heartRate = hr16 ? u16(b, off) : b[off];
  off += hr16 ? 2 : 1;

  let energyExpended = null;
  if (energyPresent) {
    need(2, "Energy Expended");
    energyExpended = u16(b, off);
    off += 2;
  }

  const rrIntervals = [];
  if (rrPresent) {
    const remaining = b.length - off;
    if (remaining < 2) throw new HeartRateParseError("RR-Interval is flagged present but no RR octets follow", b);
    if (remaining % 2 !== 0) throw new HeartRateParseError("an odd number of octets follows: RR intervals are uint16", b);
    for (; off < b.length; off += 2) rrIntervals.push(u16(b, off));
  } else if (off !== b.length) {
    throw new HeartRateParseError(`${b.length - off} octet(s) left over after the fields the flags announce`, b);
  }

  return {
    heartRate,
    format: hr16 ? "uint16" : "uint8",
    sensorContact: contactSupported ? (contactDetected ? "detected" : "not detected") : "not supported",
    energyExpended,
    energyUnit: energyPresent ? "joule (SIG unit)" : null,
    rrIntervals,
    rrMs: rrIntervals.map((v) => (v * 1000) / RR_UNITS_PER_SECOND),
    rrPresent,
    reservedBits,
  };
}

/** Wire it to Web Bluetooth: `characteristic.addEventListener("characteristicvaluechanged", onHeartRate(cb))`. */
export function onHeartRate(callback, onError = () => {}) {
  return (event) => {
    try {
      callback(parseHeartRateMeasurement(event.target.value));
    } catch (err) {
      onError(err);
    }
  };
}
```

## How to adapt it

**Subscribe, then parse.** In a browser: `requestDevice({ filters: [{ services: ["heart_rate"] }] })`, get the `heart_rate_measurement` characteristic, `startNotifications()`, and add `onHeartRate(cb)` as the `characteristicvaluechanged` listener.

**Keep RR runs separate.** RR intervals are consecutive only within one connection; compute HRV per uninterrupted run, never across a reconnect.

**Do not treat energy as calories.** Report it with its unit, or ignore it.

## The contract the tests hold it to

The hard-coded bit positions, the RR divisor of 1024 and the energy unit are checked against the SIG structure in `src/data/bluetoothGatt.ts`. The Web Bluetooth example (70 bpm, contact, 750, RR 890 and 870) decodes exactly. For 48 combinations of heart-rate format, contact state, energy and zero to three RR values, the parser agrees field for field with the site's structure-driven decoder. Contact is three-valued, uint16 is little-endian, reserved bits are reported, six kinds of malformed value throw, all input types parse, and the listener routes errors to its error callback.

## What it deliberately does not do

It does not connect to devices, compute HRV or filter artifacts, and it does not decode Body Sensor Location (0x2A38) or other characteristics; [the GATT explorer](/tools/ble-gatt-explorer) covers those.

## FAQ

### Why is the sensor contact field in a heart rate measurement three-valued?

Because two bits encode it and they mean different things. In the Bluetooth SIG's structure, bit 2 of the Flags octet says Sensor Contact Supported and bit 1 says Sensor Contact detected. When bit 2 is clear the device does not report contact at all, which is not the same as reporting no contact, so a parser that returns a boolean turns not supported into false and makes a chest strap that never reports contact look permanently detached. The recipe returns detected, not detected or not supported, and treats bit 1 as meaningless whenever bit 2 is clear.

[Permalink](https://aifitnessapi.com/cookbook/ble-heart-rate-parser#faq-1)

### How do you convert BLE RR-interval values to milliseconds?

Multiply by 1000 and divide by 1024. The Bluetooth SIG's structure says each RR-Interval value is represented by a uint16 with 1/1024 second as the unit, so a raw 1024 is exactly one second and a raw 890 is about 869 milliseconds. A notification can carry several RR values, as many as fit after the fields before them, and a device that never sets bit 4 of the Flags octet sends none, in which case heart rate variability cannot be computed from that device at all. The recipe returns both the raw values and the milliseconds.

[Permalink](https://aifitnessapi.com/cookbook/ble-heart-rate-parser#faq-2)

### What unit is the Energy Expended field of a heart rate measurement in?

The Bluetooth SIG's structure gives it the unit org.bluetooth.unit.energy.joule, and the recipe reports the raw uint16 with that unit. The Web Bluetooth specification's own heart-rate example, which the site's heart-rate lab also quotes, comments its value of 750 as 750kJ. The two sources disagree on the scale, so the recipe does not convert: it returns the number and the SIG's unit, and you decide from your device's documentation. In practice most apps take energy from the platform's calorie fields rather than from this characteristic.

[Permalink](https://aifitnessapi.com/cookbook/ble-heart-rate-parser#faq-3)
