Electronics Design AU
ESP32

How Does BLE Work on the ESP32?

Last updated 28 June 2026 · 7 min read

Direct Answer

BLE on the ESP32 is structured around the GATT (Generic Attribute Profile) protocol: a peripheral device hosts a GATT server containing one or more services; each service contains characteristics that clients (central devices, typically smartphones) can read, write, or subscribe to for notifications. In ESP-IDF, BLE is implemented by two stacks: NimBLE (recommended for new projects — smaller footprint, C API, better documented) and Bluedroid (legacy — required only for Bluetooth Classic or existing Bluedroid-based code). Setting up a BLE peripheral in NimBLE involves initialising the stack, defining a GATT service table with UUIDs and attribute properties, starting advertising with the desired advertising data (device name, service UUIDs), and handling read/write callbacks in the service event handler.

Detailed Explanation

Bluetooth Low Energy (BLE) on the ESP32 enables short-range wireless communication with smartphones, tablets, and BLE gateways at low power. Understanding BLE's protocol structure — advertising, connections, GATT, services, and characteristics — is essential before writing any BLE firmware.

BLE Architecture: Roles and Layers

BLE defines two roles:

  • Peripheral: broadcasts advertising packets and hosts a GATT server. Typically the embedded device (sensor, actuator, ESP32 product).
  • Central: scans for peripherals, initiates connections, and acts as a GATT client. Typically a smartphone or BLE gateway.

The connection sequence:

  1. Peripheral advertises (sends ADV_IND packets on channels 37, 38, 39).
  2. Central scans, finds the advertisement, and sends a connection request.
  3. Both devices enter a connected state with a negotiated connection interval (how often they exchange packets).
  4. Central discovers the peripheral's GATT services and characteristics.
  5. Central reads/writes characteristics or subscribes to notifications.

GATT: Services and Characteristics

GATT (Generic Attribute Profile) organises data into a hierarchy:

GATT Server (on the Peripheral)
│
├── Service 1 (UUID: 0x180F — Battery Service)
│   └── Characteristic: Battery Level (UUID: 0x2A19)
│       ├── Properties: READ, NOTIFY
│       ├── Value: uint8_t percentage
│       └── CCCD (Client Characteristic Configuration Descriptor)
│           └── Written by client to enable/disable notifications
│
└── Service 2 (UUID: 12345678-... — Custom Service)
    ├── Characteristic A: Sensor Data (UUID: 87654321-...)
    │   ├── Properties: READ, NOTIFY
    │   └── Value: float[3]
    └── Characteristic B: Configuration (UUID: ABCDEF01-...)
        ├── Properties: READ, WRITE
        └── Value: uint8_t config_flags

UUIDs:

  • Standard Bluetooth SIG services and characteristics use 16-bit UUIDs (e.g. 0x180F for Battery Service, 0x2A19 for Battery Level). These are assigned by the Bluetooth SIG and have defined formats.
  • Custom services and characteristics use 128-bit UUIDs generated by the application developer. Generate them with uuidgen or an online UUID generator.

Characteristic properties define what operations are permitted:

  • READ — central can read the current value.
  • WRITE / WRITE_NO_RSP — central can write a new value (with/without ATT acknowledgement).
  • NOTIFY — peripheral can push value changes without the central polling; no acknowledgement.
  • INDICATE — same as notify but with ATT-level acknowledgement from the central.

NimBLE vs Bluedroid

ESP-IDF includes two BLE stacks:

NimBLE (recommended):

  • Apache NimBLE port, maintained by Espressif alongside the upstream project.
  • BLE-only — no Bluetooth Classic support.
  • Smaller: ~20–50 KB less flash and ~20 KB less RAM than Bluedroid for equivalent BLE functionality.
  • Cleaner C API with a table-driven GATT service registration model.
  • Available on all current ESP32 variants (including RISC-V chips where Bluedroid is not available).

Bluedroid (legacy):

  • Ported from Android's Bluetooth stack.
  • Supports Bluetooth Classic (A2DP, SPP, HFP, A2DP sink) in addition to BLE.
  • Required if the project uses Classic Bluetooth.
  • Larger footprint; not available on ESP32-C3, C6, S3, or H2.

Enable NimBLE in idf.py menuconfig → Component config → Bluetooth → Host → NimBLE.

BLE Peripheral with NimBLE: Code Structure

#include "esp_nimble_hci.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"

/* Characteristic read handler */
static int gatt_svr_chr_access(uint16_t conn_handle, uint16_t attr_handle,
                                struct ble_gatt_access_ctxt *ctxt, void *arg) {
    if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
        uint8_t sensor_value = 42;   /* replace with real reading */
        os_mbuf_append(ctxt->om, &sensor_value, sizeof(sensor_value));
        return 0;
    }
    return BLE_ATT_ERR_UNLIKELY;
}

/* GATT service definition using NimBLE's table-driven API */
static const struct ble_gatt_svc_def gatt_svcs[] = {
    {
        .type = BLE_GATT_SVC_TYPE_PRIMARY,
        .uuid = BLE_UUID128_DECLARE(0x12, 0x34, /* ... custom 128-bit UUID */),
        .characteristics = (struct ble_gatt_chr_def[]) {
            {
                .uuid = BLE_UUID128_DECLARE(0x87, 0x65, /* ... */),
                .access_cb = gatt_svr_chr_access,
                .flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY,
            },
            { 0 },    /* terminator */
        },
    },
    { 0 },    /* terminator */
};

/* Advertising: what the peripheral broadcasts before connection */
static void start_advertising(void) {
    struct ble_hs_adv_fields fields = {
        .flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP,
        .name = (uint8_t *)"ESP32-Sensor",
        .name_len = strlen("ESP32-Sensor"),
        .name_is_complete = 1,
    };
    ble_gap_adv_set_fields(&fields);

    struct ble_gap_adv_params adv_params = {
        .conn_mode = BLE_GAP_CONN_MODE_UND,    /* undirected connectable */
        .disc_mode = BLE_GAP_DISC_MODE_GEN,    /* general discoverable */
        .itvl_min = BLE_GAP_ADV_ITVL_MS(100), /* 100 ms advertising interval */
        .itvl_max = BLE_GAP_ADV_ITVL_MS(250),
    };
    ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
                      &adv_params, gap_event_handler, NULL);
}

Sending Notifications

After a central subscribes to a notify characteristic (by writing 0x0001 to the CCCD), the peripheral can push data at any time:

static uint16_t sensor_chr_val_handle;    /* stored during GATT service registration */

void notify_sensor_value(uint16_t conn_handle, uint8_t value) {
    struct os_mbuf *om = ble_hs_mbuf_from_flat(&value, sizeof(value));
    ble_gatts_notify_custom(conn_handle, sensor_chr_val_handle, om);
}

Call this from an application task (not an ISR) whenever the sensor value changes or at a fixed interval. Notification rate is limited by the negotiated connection interval; attempting to notify faster than the connection interval can cause packets to be queued or dropped.

Advertising Interval and Power

The advertising interval is the dominant factor in BLE power consumption during the unconnected phase:

  • Shorter interval (e.g. 20–100 ms): faster to discover but higher average current — each advertising event draws a ~15 mA peak for approximately 300 µs per advertising channel.
  • Longer interval (e.g. 500–1000 ms): slower discovery, much lower average current.

For battery-powered peripherals, an advertising interval of 250–1000 ms is typical. Once connected, the connection interval (set by the central, negotiable by the peripheral) drives the power consumption: 7.5 ms connection interval = highest throughput/power; 1000 ms = lowest throughput/power.

For a detailed breakdown of power consumption at each operating mode, see ESP32 deep sleep and power management.

For BLE firmware development on ESP32 — custom GATT services, smartphone app integration, or BLE-to-cloud gateway architecture — Zeus Design's firmware team delivers production BLE firmware for connected IoT products.

Design Considerations

  • MTU negotiation improves throughput significantly. The default ATT MTU is 23 bytes, giving a payload of only 20 bytes per packet. Initiate MTU negotiation in the connection event handler: ble_att_set_preferred_mtu(512) on the peripheral side, and the central should also request the preferred MTU. With 512-byte MTU, notify payloads up to 509 bytes fit in a single packet — important for bulk data transfer.
  • Use connection parameter updates for power-sensitive peripherals. After a connection is established, the central sets the initial connection interval. The peripheral can request a longer interval by sending an L2CAP Connection Parameter Update Request. On iOS and Android, the system typically negotiates to a shorter interval initially (for service discovery), then the peripheral can request a longer interval for sustained operation.
  • Secure pairing for sensitive characteristics. Characteristics containing user data or device control should require bonding (pairing with LTK exchange) rather than being accessible to any device. NimBLE supports secure pairing with BLE_SM_PAIR_* flags on the service definition.
  • For BLE-only, coin-cell designs, consider the nRF52 family. The ESP32's deep sleep current is substantially higher than Nordic nRF52 devices (sub-2 µA in System OFF). When Wi-Fi is not required and ultra-low-power BLE is the primary constraint, the nRF52 family — nRF52840, nRF52833, or nRF52832 — is often the better platform choice.

Common Mistakes

  • Sending notifications from an ISR. NimBLE's ble_gatts_notify_custom() must be called from a FreeRTOS task context, not from an ISR. Queue the data from the ISR using a FreeRTOS queue, and call notify from the BLE task or a dedicated notification task.
  • Not handling disconnection events. If the central disconnects (phone screen lock, BLE range, app close), the peripheral must restart advertising from the GAP event handler's BLE_GAP_EVENT_DISCONNECT case. Without this, the device is unreachable until reboot.
  • Using 16-bit UUIDs for custom services. 16-bit UUIDs are reserved by the Bluetooth SIG — registering a custom service with a 16-bit UUID may conflict with a standard service and cause unexpected behaviour on some central stacks. Always use 128-bit UUIDs for custom applications.
  • Forgetting that BLE and Wi-Fi share the 2.4 GHz radio on ESP32. The ESP32 uses time-division multiplexing of one 2.4 GHz radio between Wi-Fi and BLE. When both are active simultaneously, each receives less radio time — Wi-Fi throughput and BLE connection stability both degrade under high load. For critical BLE connections, reduce Wi-Fi activity or schedule them to avoid overlap. A related hardware constraint: ADC2 channels cannot be read while BLE or Wi-Fi is active — see GPIO, ADC, and timers on the ESP32 for ADC channel constraints in designs that combine BLE with analog sensor readings.

Frequently Asked Questions

What is the difference between notify and indicate in BLE?
Both notify and indicate push data from the peripheral to the central without the central polling. The difference is acknowledgement: notifications are unacknowledged — the peripheral sends the ATT notification PDU and continues without waiting for a response. Indicates are acknowledged — the peripheral waits for the central to send an ATT confirmation before sending another indication. Notifications have higher throughput (no round-trip wait) but no delivery guarantee. Indicates guarantee delivery at the ATT layer but have lower throughput. For high-rate sensor data streams (accelerometer, ADC), notifications are preferred. For reliable critical events (alarm states, configuration changes), indicates are preferred. The client enables one or the other by writing to the CCCD (Client Characteristic Configuration Descriptor) of the characteristic.
Should I use NimBLE or Bluedroid for a new ESP32 project?
NimBLE for new projects with BLE only. NimBLE uses approximately 20–50 KB less flash and 20 KB less RAM than Bluedroid for equivalent BLE functionality, has a cleaner C API, and is actively maintained as the recommended ESP-IDF BLE stack. Bluedroid is required if you need Bluetooth Classic (A2DP audio, SPP serial port, HFP hands-free) — NimBLE does not support Classic Bluetooth. On the ESP32-C3, S3, C6, and H2 (which have no Bluetooth Classic radio), NimBLE is the only option and Bluedroid is not available.
What is the maximum BLE throughput on the ESP32?
Practical BLE throughput on the ESP32 with NimBLE in BLE 4.2 mode is typically 70–100 kbps end-to-end for notify packets, limited by ATT MTU (default 23 bytes per packet, negotiable up to 512 bytes), connection interval (typically 7.5–20 ms), and the ATT overhead. With maximum MTU (512 bytes) and minimum connection interval (7.5 ms), theoretical throughput approaches ~500 kbps; in practice 200–400 kbps is achievable with optimised connection parameters on BLE 5.0 with data length extension (DLE). For comparison, Wi-Fi on the same ESP32 provides 2–5 Mbps TCP/UDP throughput — BLE is not a substitute for high-bandwidth streaming.

References

Related Questions

Related Forum Discussions