React Native BLE Notifications vs Indications, how to subscribe, confirm delivery, and stop leaks (long-tail: “react native ble notifications indications”)

If you’ve ever had a BLE screen working fine, then suddenly started getting duplicate packets, phantom callbacks after leaving the screen, or “randomly missing” sensor updates, you’re not alone. Most of those bugs come from two places: misunderstanding react native ble notifications indications behavior, and not cleaning up subscriptions correctly.
This guide focuses on practical, code-first patterns in TypeScript for the two most common React Native BLE libraries, plus what “delivery confirmation” really means in BLE. You’ll also get cleanup and troubleshooting checklists that match how iOS and Android behave in 2026.
Notifications vs indications: same subscription, different guarantees
Think of notifications like a postcard. The peripheral sends data and moves on. If one card falls behind the fridge, nobody knows. Indications are more like registered mail. The peripheral sends, then waits for a receipt before it sends the next one.
Here’s the key part that trips people up: the ACK for indications is not an app callback. It happens at the BLE ATT layer, inside the OS Bluetooth stack.
| Topic | Notification | Indication | | --------------------- | --------------------- | ------------------------------------- | | Delivery confirmation | No ATT ACK | Yes ATT ACK | | Throughput | Higher | Lower (waits for ACK) | | Typical use | Live sensor streaming | Critical events, “must not miss” data | | CCCD value (0x2902) | 0x0100 | 0x0200 |
Both modes require the client to enable updates by writing the CCCD descriptor (UUID 0x2902). Many peripherals accept either, some expose only one property (Notify or Indicate). In app code, you usually “monitor” a characteristic and the OS chooses the correct mechanism based on the characteristic’s properties. When things fail, it often means the CCCD write did not stick, or the library wrote notify (0x0100) but your device only supports indicate (0x0200).
For the ble-plx side, keep the official docs handy, starting with the react-native-ble-plx documentation and the Characteristic Notifying wiki page.
react-native-ble-plx: monitor a characteristic, decode base64, cancel transactions
In react-native-ble-plx, monitoring returns a subscription you must remove. If you don’t, it can keep firing after navigation changes, and it can stack up after reconnects.
A hook pattern that avoids leaks:
import { useEffect, useRef, useState } from 'react';
import type { Subscription } from 'react-native-ble-plx';
import { Buffer } from 'buffer';
type UseStreamArgs = { deviceId: string; serviceUUID: string; charUUID: string; enabled: boolean };
export function usePlxStream(manager: any, { deviceId, serviceUUID, charUUID, enabled }: UseStreamArgs) {
`const [lastValue, setLastValue] = useState<Uint8Array | null>(null);`
const subRef = useRef<Subscription | null>(null);
const txId = useRef(\mon:${deviceId}:${serviceUUID}:${charUUID}\);useEffect(() => {if (!enabled) return;subRef.current?.remove();manager.cancelTransaction?.(txId.current);subRef.current = manager.monitorCharacteristicForDevice(deviceId, serviceUUID, charUUID,(error: any, c: any) => {if (error || !c?.value) return;const bytes = Buffer.from(c.value, 'base64');setLastValue(new Uint8Array(bytes));},txId.current);return () => { subRef.current?.remove(); manager.cancelTransaction?.(txId.current); };` }, \[enabled, deviceId, serviceUUID, charUUID, manager\]);return { lastValue };}\`
A few details that matter in production:
- Prefer a stable BleManager instance (Context or singleton). Recreating it per screen can multiply callbacks and keep native resources alive.
- Cancel by transactionId in addition to
remove(). In real apps, navigation and reconnect races happen, and canceling gives you a second “brake pedal”. - Base64 decoding: ble-plx surfaces characteristic values as base64 strings. Decode to bytes first, then parse (JSON, structs, protobuf, whatever). Don’t parse base64 as UTF-8 unless you know it’s text.
Writing CCCD when you must
Most of the time ble-plx handles CCCD when monitoring starts. If you have a peripheral that needs a manual CCCD write (common with picky firmware), you’re writing two bytes to descriptor 0x2902:
- Notify: `[0x01, 0x00]`
- Indicate: `[0x02, 0x00]`
Generate the payload safely:
`const cccdNotify = Buffer.from([0x01, 0x00]).toString('base64');`
`const cccdIndicate = Buffer.from([0x02, 0x00]).toString('base64');`
Then use the library’s descriptor write API (names vary by version, confirm in the react-native-ble-plx docs) to write descriptor UUID 00002902-0000-1000-8000-00805f9b34fb.
react-native-ble-manager: startNotification, listen once, stopNotification always
react-native-ble-manager works differently. You call startNotification(peripheralId, serviceUUID, characteristicUUID), then you listen to native events. If you forget to remove the event listener, you’ll get duplicates after every remount. If you forget stopNotification, you can keep receiving updates even after you think you’re “done”.
The method surface is documented here: react-native-ble-manager methods. Installation and platform notes are also commonly referenced from the react-native-ble-manager package page.
A hook pattern with strict cleanup:
import { useEffect, useMemo, useState } from 'react';
import { NativeEventEmitter, NativeModules } from 'react-native';
import BleManager from 'react-native-ble-manager';
import { Buffer } from 'buffer';
const emitter = new NativeEventEmitter(NativeModules.BleManager);
type Args = { peripheralId: string; serviceUUID: string; charUUID: string; enabled: boolean };
export function useBleManagerStream({ peripheralId, serviceUUID, charUUID, enabled }: Args) {
`const [lastValue, setLastValue] = useState<Uint8Array | null>(null);`
useEffect(() => {
if (!enabled) return;
let sub: any;
(async () => {
await BleManager.startNotification(peripheralId, serviceUUID, charUUID);
sub = emitter.addListener('BleManagerDidUpdateValueForCharacteristic', (e: any) => {
if (e.peripheral !== peripheralId) return;
if (e.characteristic?.toLowerCase() !== charUUID.toLowerCase()) return;
const b64 = Buffer.from(e.value).toString('base64');
const bytes = Buffer.from(b64, 'base64');
setLastValue(new Uint8Array(bytes));
});
})();
return () => { sub?.remove?.(); BleManager.stopNotification(peripheralId, serviceUUID, charUUID).catch(() => {}); };
`}, [enabled, peripheralId, serviceUUID, charUUID]);`
return { lastValue };
}
Notes that save hours:
BleManagerDidUpdateValueForCharacteristicfires for every notify source. Filter by peripheral and characteristic or you’ll mix streams.- Call
stopNotificationeven if you’re also disconnecting. Disconnect paths can fail, and you don’t want the “old” subscription to survive into a reconnect. - If you see duplicated packets, the cause is often “listener added twice”, not the peripheral sending duplicates.
About indications: this library generally exposes “notification start/stop” APIs, and the native stack decides notify vs indicate from characteristic properties. If the device only supports indications and you still get nothing, suspect CCCD handling, or a firmware requirement for pairing.
Confirming delivery: what you can prove, and what you can’t
Indications have built-in delivery confirmation, but it’s not an event you can reliably observe in JavaScript. The peripheral knows it got an ACK (or didn’t) and paces itself. Your app mostly benefits indirectly: fewer missed critical updates.
Notifications have no ATT-level confirmation, so if you need proof, add an application-level ACK. A simple pattern:
- Peripheral sends
{ seq: 1042, payload: ... }on a notify characteristic. - App writes back
seq=1042to a separate “ack” characteristic (Write With Response if you want client side backpressure). - Peripheral retries if it doesn’t receive the ACK within a timeout.
That turns a “postcard stream” into “postcards plus receipts”. It also makes debugging easier because you can log missing sequence numbers.
2026 platform checklist (permissions, background, reconnect cleanup)
Keep this short checklist near your BLE module:
- Android permissions (2026 reality): if you target Android 12 and up, you need runtime grants for
BLUETOOTH_SCANandBLUETOOTH_CONNECT. Older Android targets still involve location permission for scanning. If scanning silently returns nothing, it’s usually permissions, not BLE. - Android long-running connections: if you stay connected in the background, plan for a foreground service on Android, or the OS will eventually throttle you.
- iOS background mode: if you need background BLE, enable the right Background Modes capability (Central role) and expect stricter behavior when the app is suspended.
- Bonding and encryption: some devices won’t send indications until paired (bonded). Symptoms look like “subscribe works, but nothing arrives”.
- Reconnect without leaks: on disconnect, remove monitor subscriptions, cancel transactions, and stop notifications. On reconnect, add them once. Track a “connection generation” number if your UI can trigger reconnect rapidly.
If you want a quick reminder of how subscriptions are intended to work in ble-plx, the Characteristic Notifying wiki page is a good reference point.
Conclusion
Most BLE headaches in React Native come down to two truths: indications confirm delivery at the ATT layer, and your app must clean up subscriptions like it means it. Pick notifications for speed, indications for “must not miss”, and add an app-level ACK when you need proof for a notify stream. Once monitoring and cleanup live inside a hook with strict teardown, the duplicate events and memory leaks usually disappear for good.