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:

  1. The peripheral's GATT server determines that the characteristic value has changed (new EEG sample, new sensor reading, etc.).
  2. 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.
  3. At the next connection event, the peripheral sends an ATT_HANDLE_VALUE_NTF PDU containing the characteristic handle and the new value.
  4. The central (Android) receives it, and the BLE stack delivers it to your onCharacteristicChanged callback.
  5. 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:

  1. The peripheral sends an ATT_HANDLE_VALUE_IND PDU with the characteristic handle and value.
  2. The central receives it and delivers it to onCharacteristicChanged (same callback as notifications).
  3. The central's BLE stack automatically sends an ATT_HANDLE_VALUE_CFM back to the peripheral.
  4. 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:

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:

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:

Use Indications For:

The Hybrid Approach

Most well-designed BLE profiles for medical devices use both. A typical architecture we implement at DEVSFLOW looks like this:

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:

  1. Verify the CCCD write succeeded. Check the status parameter in onDescriptorWrite. A status of 0 means success. Any other value means the peripheral rejected the write.
  2. 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.).
  3. 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.
  4. 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.
  5. Check for GATT operation queuing issues. If you are enabling notifications on multiple characteristics simultaneously without waiting for each onDescriptorWrite callback, 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.