BLE Notifications vs Indications on Android: When to Use Which and Why It Matters
One of the earliest design decisions when building a BLE peripheral profile is whether each data characteristic should use notifications or indications. They look almost identical in the Android API, and many developers treat them as interchangeable. They are not. The choice affects throughput, reliability, power consumption, and how your app behaves when the Bluetooth link is congested. For biosignal streaming applications where data integrity matters, understanding the distinction is essential.
We have worked through this decision on multiple neurotechnology projects here in Toronto, building companion apps for hardware teams at RE-AK Technologies and CLEIO. The mistakes are remarkably consistent across teams: forgotten descriptor writes, wrong CCCD values, and indications used where notifications should be (or vice versa). This article covers the mechanics of both, the correct way to enable them on Android, throughput and reliability tradeoffs, and concrete guidance for medical device data.
How BLE Notifications Work
A notification is a server-initiated (peripheral-initiated) message that sends the current value of a characteristic to the client (your Android app) without requiring the client to read it. The key property of notifications is that they are unacknowledged at the GATT layer. The peripheral sends the data, and that is the end of the transaction from its perspective.
Here is what happens at the protocol level when a notification fires:
- The peripheral's GATT server determines that the characteristic value has changed (new EEG sample, new sensor reading, etc.).
- The peripheral checks if the connected client has enabled notifications for this characteristic by reading the Client Characteristic Configuration Descriptor (CCCD). If the notification bit is set, it proceeds.
- At the next connection event, the peripheral sends an ATT_HANDLE_VALUE_NTF PDU containing the characteristic handle and the new value.
- The central (Android) receives it, and the BLE stack delivers it to your
onCharacteristicChangedcallback. - No acknowledgment is sent back. The peripheral does not know whether the central received the data.
Because there is no acknowledgment round trip, the peripheral can send multiple notifications within a single connection event. With a connection interval of 7.5 ms and data length extension enabled, you can fit up to 6 notification PDUs in one event. This is what makes notifications the high-throughput option for BLE data transfer.
The unacknowledged nature does not mean notifications are unreliable at the radio level. BLE's link layer still performs CRC checks and automatic retransmission of corrupted packets. What "unacknowledged" means here is that there is no GATT-layer confirmation. If the link layer retransmission budget is exhausted (typically 2 retries) and the packet still has not gotten through, the notification is silently dropped. The peripheral will not re-send it.
How BLE Indications Work
An indication is the acknowledged counterpart to a notification. The peripheral sends the characteristic value, and then it waits for the central to send back an ATT_HANDLE_VALUE_CFM (confirmation) before it can send the next indication.
The protocol flow:
- The peripheral sends an ATT_HANDLE_VALUE_IND PDU with the characteristic handle and value.
- The central receives it and delivers it to
onCharacteristicChanged(same callback as notifications). - The central's BLE stack automatically sends an ATT_HANDLE_VALUE_CFM back to the peripheral.
- Only after receiving the confirmation can the peripheral send the next indication.
This confirmation handshake guarantees that every indication is received by the GATT layer on the central side. If the confirmation does not arrive within 30 seconds (the ATT transaction timeout), the peripheral will disconnect.
The cost is throughput. Because the peripheral must wait for a round trip before sending the next indication, you can only send one indication per connection event (sometimes two if the timing works out). At a 15 ms connection interval, that gives you roughly 67 indications per second, each carrying up to 244 bytes (with MTU 247). That is about 16 KB/s. With notifications at the same interval, you can achieve 3 to 4 times that throughput.
Enabling Notifications and Indications: The CCCD Write
This is where most Android BLE bugs live. Calling setCharacteristicNotification() on the Android side is necessary but not sufficient. That method only tells the local Bluetooth stack to route incoming notifications to your callback. It does not tell the peripheral to start sending them. For that, you must write to the Client Characteristic Configuration Descriptor (CCCD).
The CCCD is a descriptor with the standard UUID 00002902-0000-1000-8000-00805f9b34fb. It is a 2-byte value where bit 0 enables notifications and bit 1 enables indications.
Here is the complete, correct way to enable notifications on Android:
fun enableNotifications(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
): Boolean {
// Step 1: Tell the local stack to deliver notifications to our callback
val success = gatt.setCharacteristicNotification(characteristic, true)
if (!success) {
Log.e(TAG, "setCharacteristicNotification failed")
return false
}
// Step 2: Write to the CCCD to tell the peripheral to start sending
val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val descriptor = characteristic.getDescriptor(cccdUuid)
if (descriptor == null) {
Log.e(TAG, "CCCD descriptor not found for ${characteristic.uuid}")
return false
}
// For notifications: use ENABLE_NOTIFICATION_VALUE
// For indications: use ENABLE_INDICATION_VALUE
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
return gatt.writeDescriptor(descriptor)
}
And for indications:
fun enableIndications(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
): Boolean {
gatt.setCharacteristicNotification(characteristic, true)
val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val descriptor = characteristic.getDescriptor(cccdUuid)
?: return false
// Note: ENABLE_INDICATION_VALUE, not ENABLE_NOTIFICATION_VALUE
descriptor.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
return gatt.writeDescriptor(descriptor)
}
On Android 13+ (API 33), the API changed to use writeDescriptor(descriptor, value) with an explicit byte array parameter:
// Android 13+ (API 33) approach
fun enableNotificationsApi33(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
): Boolean {
gatt.setCharacteristicNotification(characteristic, true)
val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val descriptor = characteristic.getDescriptor(cccdUuid)
?: return false
val result = gatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
)
return result == BluetoothStatusCodes.SUCCESS
}
The Most Common Mistake
The single most common BLE bug on Android is calling setCharacteristicNotification() without writing to the CCCD. The developer sees that the method returned true, assumes notifications are enabled, and then wonders why onCharacteristicChanged never fires. The local stack is ready to receive, but the peripheral was never told to start sending.
The second most common mistake is writing the wrong value to the CCCD. If the characteristic supports indications (not notifications), writing ENABLE_NOTIFICATION_VALUE will either fail silently or be ignored by the peripheral. Always check the characteristic's properties to determine which mode is supported:
fun enableCharacteristicUpdates(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
): Boolean {
val properties = characteristic.properties
val cccdValue = when {
properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 ->
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0 ->
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else -> {
Log.e(TAG, "Characteristic supports neither notify nor indicate")
return false
}
}
gatt.setCharacteristicNotification(characteristic, true)
val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val descriptor = characteristic.getDescriptor(cccdUuid)
?: return false
descriptor.value = cccdValue
return gatt.writeDescriptor(descriptor)
}
Throughput: Notifications vs Indications in Numbers
To put concrete numbers on the throughput difference, here are measurements from our test bench using a Nordic nRF52840 peripheral and a Pixel 8 Pro central, with MTU negotiated to 247 bytes and data length extension active:
- Notifications at 7.5 ms CI: Up to 6 PDUs per event, each carrying 244 bytes of payload. Theoretical max: ~195 KB/s. Practical sustained rate: ~100-120 KB/s (accounting for scheduling jitter and other BLE overhead).
- Notifications at 15 ms CI: Practical sustained rate: ~50-70 KB/s.
- Notifications at 30 ms CI: Practical sustained rate: ~25-35 KB/s.
- Indications at 7.5 ms CI: One indication per round trip. Practical rate: ~20-30 KB/s.
- Indications at 15 ms CI: Practical rate: ~12-16 KB/s.
- Indications at 30 ms CI: Practical rate: ~6-8 KB/s.
The throughput gap is roughly 3x to 5x in favor of notifications. For a 32-channel EEG device at 250 Hz with 24-bit resolution, the raw data rate is about 24 KB/s. Notifications at a 15 ms interval handle this easily with headroom to spare. Indications at a 15 ms interval would be right at the edge, with no margin for auxiliary data channels.
Reliability Tradeoffs: When Data Loss Matters
The throughput advantage of notifications comes with a reliability tradeoff. In normal conditions with a clean RF link, notifications are perfectly reliable because the link layer handles retransmission of corrupted packets. The failure mode is when the link layer retransmission budget is exhausted and a packet is truly lost.
In practice, this happens under specific conditions:
- RF congestion: Crowded 2.4 GHz environments (hospitals, offices with many Wi-Fi access points, trade show floors) increase packet error rates.
- Distance: When the peripheral is near the edge of its radio range, error rates climb.
- Interference: Microwave ovens, certain USB 3.0 devices, and other 2.4 GHz emitters can cause burst errors.
- Stack overload: If the Android BLE stack's internal buffer fills up (because the app is not reading notifications fast enough), new notifications are silently dropped.
Indications protect against the first three failure modes by ensuring GATT-layer acknowledgment. If the indication cannot be delivered and confirmed, the peripheral knows about it and can take action (buffer the data, retry, or flag an error). But indications do not protect against the fourth failure mode. If the Android stack's buffer is full, even the confirmation response will be delayed, and the peripheral will eventually hit the 30-second ATT timeout and disconnect.
For most biosignal streaming applications, the right approach is to use notifications for the high-frequency data stream and handle reliability at the application layer. This means adding sequence numbers to your data packets and detecting gaps on the Android side:
class PacketLossDetector {
private var expectedSequence: UByte = 0u
private var totalPackets = 0L
private var lostPackets = 0L
fun onPacketReceived(sequenceNumber: UByte, payload: ByteArray) {
totalPackets++
if (sequenceNumber != expectedSequence) {
// Calculate how many packets we missed
val gap = if (sequenceNumber > expectedSequence) {
(sequenceNumber - expectedSequence).toInt()
} else {
// Sequence wrapped around (0-255)
(256u + sequenceNumber - expectedSequence).toInt()
}
lostPackets += gap - 1 // gap of 1 means no loss
Log.w(TAG, "Packet loss detected: expected=$expectedSequence, " +
"got=$sequenceNumber, lost=${gap - 1}")
}
expectedSequence = (sequenceNumber + 1u).toUByte()
}
fun getLossRate(): Double {
if (totalPackets == 0L) return 0.0
return lostPackets.toDouble() / (totalPackets + lostPackets)
}
}
When to Use Each for Medical and Biosignal Data
Based on our experience building BLE medical device apps for Canadian neurotechnology companies, here is our framework for choosing between notifications and indications:
Use Notifications For:
- Continuous biosignal streams: EEG, EMG, ECG, PPG, accelerometer data. These are high-frequency, high-volume data channels where throughput is the priority. A dropped sample is unfortunate but recoverable through interpolation or flagging.
- Real-time visualization data: Any data the user sees in a live waveform view. Latency matters more than guaranteed delivery for display purposes.
- Sensor status updates: Battery level, signal quality metrics, electrode impedance. These are sent periodically and the latest value is what matters, not historical completeness.
Use Indications For:
- Device configuration acknowledgments: When your app writes a configuration change to the peripheral (sampling rate, gain settings, filter parameters), use an indication on a status characteristic to confirm the peripheral has applied the change.
- Session markers and events: Timestamps for stimulus events, user-triggered markers, or recording start/stop confirmations. Losing one of these can corrupt the entire session's data alignment.
- Firmware update progress: DFU (Device Firmware Update) packets should use indications or a write-with-response pattern to ensure every block is delivered.
- Error and alert notifications: If the device detects a hardware fault, low battery warning, or safety alert, you want guaranteed delivery.
The Hybrid Approach
Most well-designed BLE profiles for medical devices use both. A typical architecture we implement at DEVSFLOW looks like this:
- Data characteristic (notifications): Carries the raw biosignal samples at high frequency. Sequence-numbered for gap detection.
- Control characteristic (write with response): App writes commands to the peripheral (start recording, change settings).
- Status characteristic (indications): Peripheral sends confirmations, error codes, and critical events that must not be lost.
- Battery characteristic (notifications): Standard BLE battery service, updates every 30 to 60 seconds.
This gives you maximum throughput on the data channel while maintaining reliability on the control plane. The firmware side is straightforward to implement because the data and control paths are separate characteristics with different GATT properties.
Common Mistakes and How to Avoid Them
Here is a catalog of the notification and indication bugs we see most frequently when reviewing BLE codebases:
1. Forgetting the Descriptor Write
Already covered above, but it bears repeating because it accounts for roughly 40% of the "my notifications don't work" bugs we debug. Always write to the CCCD after calling setCharacteristicNotification().
2. Not Waiting for onDescriptorWrite Before Enabling the Next Characteristic
Android's BLE stack can only handle one GATT operation at a time. If you have three characteristics that need notifications enabled, you must wait for the onDescriptorWrite callback for each one before starting the next:
class SequentialNotificationEnabler(
private val gatt: BluetoothGatt,
private val characteristics: List<BluetoothGattCharacteristic>,
private val onComplete: () -> Unit
) : BluetoothGattCallback() {
private var currentIndex = 0
fun start() {
enableNext()
}
private fun enableNext() {
if (currentIndex >= characteristics.size) {
onComplete()
return
}
val char = characteristics[currentIndex]
enableCharacteristicUpdates(gatt, char)
}
override fun onDescriptorWrite(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
currentIndex++
enableNext()
} else {
Log.e(TAG, "Descriptor write failed with status $status " +
"for characteristic at index $currentIndex")
}
}
}
3. Using the Wrong CCCD UUID
The CCCD UUID is always 00002902-0000-1000-8000-00805f9b34fb. Some developers accidentally use the characteristic UUID or another descriptor UUID. This will not cause a crash, but getDescriptor() will return null, and if the null check is missing, you get a NullPointerException in production.
4. Not Handling the onCharacteristicChanged Callback on the Right Thread
On Android, onCharacteristicChanged is called on the Binder thread. If you are doing any work beyond copying the byte array (like parsing, UI updates, or database writes), you should dispatch to a dedicated thread or coroutine:
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
// Copy the data immediately - the byte array may be reused
val data = value.copyOf()
val uuid = characteristic.uuid
// Dispatch parsing to a coroutine on IO dispatcher
scope.launch(Dispatchers.IO) {
when (uuid) {
EEG_DATA_UUID -> parseEegPacket(data)
STATUS_UUID -> parseStatusIndication(data)
BATTERY_UUID -> parseBatteryLevel(data)
}
}
}
5. Enabling Indications on a Notification-Only Characteristic (or Vice Versa)
If the characteristic's GATT properties only include PROPERTY_NOTIFY, writing ENABLE_INDICATION_VALUE to the CCCD will be rejected by the peripheral. The write will succeed at the Android level (you will get onDescriptorWrite with GATT_SUCCESS on some stacks), but the peripheral will ignore the incorrect bit. Always check the characteristic properties first, as shown in the enableCharacteristicUpdates function earlier in this article.
6. Not Disabling Notifications on Disconnect
When you disconnect, you should disable notifications by writing DISABLE_NOTIFICATION_VALUE to the CCCD before calling gatt.disconnect(). This is especially important for bonded devices. If the device is bonded and you simply disconnect without disabling notifications, some peripherals will buffer notifications and try to send a burst of stale data when you reconnect:
fun cleanDisconnect(
gatt: BluetoothGatt,
characteristics: List<BluetoothGattCharacteristic>
) {
val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
// Disable notifications on each characteristic
for (char in characteristics) {
gatt.setCharacteristicNotification(char, false)
val descriptor = char.getDescriptor(cccdUuid) ?: continue
descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
// In production, wait for onDescriptorWrite before the next one
}
// Then disconnect
gatt.disconnect()
}
Debugging Notification and Indication Issues
When notifications are not arriving or indications are stalling, here is a systematic debugging approach:
- Verify the CCCD write succeeded. Check the
statusparameter inonDescriptorWrite. A status of 0 means success. Any other value means the peripheral rejected the write. - Capture an HCI snoop log. Open it in Wireshark and filter for ATT protocol. Look for ATT_HANDLE_VALUE_NTF (opcode 0x1B) or ATT_HANDLE_VALUE_IND (opcode 0x1D). If you see them in the snoop log but not in your callback, the issue is in your Android code (wrong characteristic reference, callback not registered, etc.).
- Check that you called setCharacteristicNotification(). Without this, the Android stack will receive the notification at the HCI layer but will not deliver it to your BluetoothGattCallback.
- Verify the characteristic properties. Use nRF Connect (by Nordic Semiconductor, available on the Play Store) to browse the peripheral's GATT table and confirm that the characteristic has the NOTIFY or INDICATE property set.
- Check for GATT operation queuing issues. If you are enabling notifications on multiple characteristics simultaneously without waiting for each
onDescriptorWritecallback, the second and subsequent writes will be silently dropped by Android's BLE stack.
nRF Connect is particularly useful because it handles all the CCCD writes correctly and shows you in real time whether the peripheral is sending data. If notifications work in nRF Connect but not in your app, the problem is 100% in your Android code, not the firmware.
Putting It All Together: A Complete Notification Setup
Here is a complete, production-ready class that handles notification and indication setup with proper queuing, error handling, and the correct CCCD write sequence:
class BleNotificationManager(
private val gatt: BluetoothGatt,
private val scope: CoroutineScope
) {
private val cccdUuid = UUID.fromString(
"00002902-0000-1000-8000-00805f9b34fb"
)
private val operationQueue = Channel<GattOperation>(Channel.UNLIMITED)
private val operationComplete = CompletableDeferred<Int>()
sealed class GattOperation {
data class EnableUpdates(
val characteristic: BluetoothGattCharacteristic
) : GattOperation()
}
init {
// Process GATT operations sequentially
scope.launch {
for (operation in operationQueue) {
when (operation) {
is GattOperation.EnableUpdates -> {
performEnable(operation.characteristic)
}
}
}
}
}
fun enableUpdates(characteristic: BluetoothGattCharacteristic) {
operationQueue.trySend(GattOperation.EnableUpdates(characteristic))
}
private suspend fun performEnable(
characteristic: BluetoothGattCharacteristic
) {
val properties = characteristic.properties
val supportsNotify =
properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
val supportsIndicate =
properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0
if (!supportsNotify && !supportsIndicate) {
Log.e(TAG, "${characteristic.uuid}: no notify or indicate support")
return
}
// Enable local routing
gatt.setCharacteristicNotification(characteristic, true)
// Determine correct CCCD value
val cccdValue = if (supportsNotify) {
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
} else {
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
}
// Write to CCCD
val descriptor = characteristic.getDescriptor(cccdUuid)
if (descriptor == null) {
Log.e(TAG, "${characteristic.uuid}: CCCD not found")
return
}
descriptor.value = cccdValue
gatt.writeDescriptor(descriptor)
// Wait for the write to complete before processing next operation
val status = operationComplete.await()
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "CCCD write failed: status=$status " +
"for ${characteristic.uuid}")
}
}
// Call this from your BluetoothGattCallback.onDescriptorWrite
fun onDescriptorWriteComplete(status: Int) {
operationComplete.complete(status)
}
}
This pattern ensures GATT operations are serialized, CCCD values are chosen correctly based on characteristic properties, and each write completes before the next one begins. It is the foundation we use in every BLE project at DEVSFLOW, and it eliminates the entire class of "notifications only work on the first characteristic" bugs.
Notifications and indications are deceptively simple at first glance. The API surface is small, but the interaction between local stack configuration, descriptor writes, operation queuing, and peripheral behavior creates a surprising number of failure modes. Get the fundamentals right (always write the CCCD, serialize your operations, choose the right mode for each data channel) and you will avoid the issues that derail most BLE projects.
Need help building a reliable BLE notification pipeline for your medical device or neurotechnology product? Talk to DEVSFLOW Neuro. We build BLE-connected mobile apps for neurotechnology and medical device companies across Canada.