Electronics Design AU
Bluetooth & BLESolved

BLE GATT notifications working on first connect but stop after phone reconnects — what resets the CCCD?

6 min read3 replies
Original Question

Asked by stale_biscuit_03 ·

Working on a BLE peripheral on nRF52840 with Zephyr (NCS 2.7). Custom GATT service with a single notify characteristic that sends sensor readings every 500ms.

First connection works perfectly — phone app discovers the service, subscribes to notifications, and readings flow in as expected. But if the phone goes out of range or the user closes and reopens the app, the BLE link reconnects fine (I can see it in nRF Connect logs) but notifications just... stop. The peripheral connects, bt_gatt_notify() isn't returning any errors, but the phone never receives anything until I physically reboot the nRF52840.

I know about CCCD from the BLE fundamentals page — I have the BT_GATT_CCC() macro in my service definition. The phone subscribed on the first connect so I figured it stays subscribed? Is the CCCD getting wiped somewhere when the link drops?

Using an Android app that I didn't write — it's a generic GATT explorer tool that supposedly handles reconnections automatically.

From the knowledge baseWhat Is Bluetooth Low Energy (BLE)?

3 Replies

zephyr_devotee
Accepted Answer

Yes, the CCCD is getting wiped — that's exactly what's supposed to happen, and it's the source of a lot of confusion.

Why CCCD resets on disconnect

The CCCD (Client Characteristic Configuration Descriptor, UUID 0x2902) stores the client's subscription state: 0x0001 for notifications enabled, 0x0002 for indications enabled, 0x0000 for neither. The BT Core Spec defines this as per-connection state when bonding is not in use. The moment the link drops, the server clears the CCCD back to 0x0000. A new connection starts with all CCCDs disabled, regardless of what the previous connection had configured.

This is by design — if a client disconnects and a different client connects later, you don't want the GATT server to start blasting notifications at an unknown peer that never subscribed.

Diagnosing it

Add a ccc_changed callback to your service and log the value:

static void sensor_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
    bool subscribed = (value == BT_GATT_CCC_NOTIFY);
    LOG_INF("Sensor notifications %s", subscribed ? "enabled" : "disabled");
}

BT_GATT_SERVICE_DEFINE(sensor_svc,
    BT_GATT_PRIMARY_SERVICE(&sensor_svc_uuid),
    BT_GATT_CHARACTERISTIC(&sensor_char_uuid,
        BT_GATT_CHRC_NOTIFY,
        BT_GATT_PERM_NONE, NULL, NULL, NULL),
    BT_GATT_CCC(sensor_ccc_changed,
        BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
);

After reconnect, watch your serial console. If you see "Sensor notifications disabled" immediately after connect, the CCCD got cleared on disconnect as expected — but then you should see "enabled" shortly after when the client re-subscribes. If you never see "enabled" after the reconnect, your phone app is not re-writing the CCCD after reconnection, and that's the actual problem.

Fix option 1 — fix the client (phone app)

The correct behaviour for a BLE central is to re-enable CCCD notifications after every new connection, not just the first one. The generic GATT explorer you're using may not do this correctly — it may assume CCCD state persists across connections. For production, your central app must explicitly re-write 0x0001 to the CCCD handle in the connection event handler, every time a new connection is established. You can't rely on the server to remember the subscription.

Fix option 2 — enable bonding on the peripheral

With bonding enabled, the Zephyr BT stack stores CCCD subscriptions in non-volatile storage (via Settings) and restores them automatically when a bonded peer reconnects. The central does not need to re-write CCCD after each reconnect. Add to prj.conf:

CONFIG_BT_SETTINGS=y
CONFIG_FLASH=y
CONFIG_FLASH_PAGE_LAYOUT=y
CONFIG_NVS=y
CONFIG_SETTINGS=y
CONFIG_BT_BONDABLE=y

And in your application, initialise the settings backend before calling bt_enable():

int main(void)
{
    settings_load();

    int err = bt_enable(NULL);
    if (err) {
        LOG_ERR("bt_enable failed: %d", err);
        return err;
    }
    /* ... */
}

With this in place, after the phone bonds with the device (pairing occurs on first connect), subsequent reconnections by the same peer automatically restore the CCCD state from flash. Notifications resume without the phone needing to re-subscribe.

The bonding route also has the advantage that it works correctly with iOS and Android system BLE caches — both platforms re-use the bonding state and will generally restore CCCD without user interaction after a reconnect. See BLE security and pairing for the bonding setup details if you haven't been through that yet.

If you're not ready to implement bonding, ask whoever owns the phone app to fix the reconnect path — it's one additional write to the CCCD handle in the connect callback.

ble_buffoon

This one got me for an embarrassing amount of time on an ESP32/NimBLE project about a year ago. I had exactly the same setup — first connect worked, reconnect silent — and I was genuinely convinced the notify was getting dropped somewhere in the BLE controller until I looked at the wire.

If you want to confirm what's happening at the packet level before touching code, the nRF Sniffer (free from Nordic) + Wireshark will show you everything. After reconnect, look in the ATT layer for a "Write Request" to the handle of your CCCD attribute. If you see it, the phone is re-subscribing and the problem is elsewhere. If there's no write after the new connection event, the phone app isn't re-subscribing and that's your smoking gun.

On the NimBLE side (ESP32/ESP-IDF) the equivalent fix for a central app that you control is to write the CCCD explicitly in the connection event handler:

static int on_subscribe(uint16_t conn_handle,
                        const struct ble_gatt_error *error,
                        struct ble_gatt_attr *attr, void *arg)
{
    if (error->status == 0) {
        ESP_LOGI(TAG, "CCCD write succeeded");
    }
    return 0;
}

static void subscribe_to_notify(uint16_t conn_handle, uint16_t cccd_handle)
{
    uint8_t val[2] = { 0x01, 0x00 }; /* 0x0001 = notifications enabled */
    ble_gattc_write_flat(conn_handle, cccd_handle,
                         val, sizeof(val),
                         on_subscribe, NULL);
}

Call subscribe_to_notify() inside your BLE_GAP_EVENT_CONNECT handler after service discovery completes, every connection — not just the first. On the server (peripheral) side, NimBLE behaves the same as Zephyr: CCCD is per-connection, cleared on disconnect.

The mental model that finally made it click for me: CCCD is essentially a per-connection "opt-in" flag. The client opts in after each new connection. The server doesn't remember who was opted in before.

ble_mesh_maven

The bonding path Zephyr_devotee described is the right long-term answer for any product where the phone and device are expected to reconnect reliably after being out of range.

One thing worth understanding clearly: CCCD persistence via bonding isn't just a convenience feature. The BT Core Spec (Vol 3, Part G, §3.3.3.3) explicitly defines that a GATT client shall re-subscribe to notifications after reconnecting to an unbonded server, and a bonded server shall restore the subscription state for a known peer. Most phone BLE stacks implement this correctly for bonded connections but make no guarantees for unbonded ones — which is why the generic GATT tool may work on some platforms and not others.

There's also a second, subtler problem that comes up with unbonded reconnections on iOS specifically. Apple's CoreBluetooth caches GATT service/characteristic handles per device address. If your peripheral uses a resolvable private address (RPA) — which Zephyr enables by default with privacy mode on — and the phone can't resolve the address without bonding information, it may treat each reconnection as a new device, trigger a full service discovery, and still not re-write CCCD if the app developer assumed the cache would do it. Bonding fixes this too: with a bonded peer, the phone resolves the RPA and knows it's the same device, restores its GATT cache, and applies the stored CCCD state through the stack.

If you're building a product where end users shouldn't need to re-pair every time they open the app, bonding is the correct architecture. The extra Kconfig lines are a small cost relative to the reliability improvement in the field.

Related Discussions