How to Use ESP32 BLE5 Extended Advertising for Larger Payloads

You’ve got 60 bytes of sensor data to push and a 31-byte advertising payload. You open a GATT connection, negotiate an MTU, do a write, tear it down. The radio is on for 200 ms and burns a few hundred microcoulombs to deliver a packet that should have taken 2 ms to broadcast.
That tax has been optional since 2016. Most ESP32 firmware just hasn’t collected the refund.
Why 31 Bytes Stops Being Enough
Legacy BLE 4.x advertising caps you at 31 bytes of payload. Add a scan response and you get another 31, but now the scanner has to ask for it, which means you’re not really connectionless anymore. Chunking telemetry across multiple advertisements breaks down once you care about atomicity or ordering.
The connection workaround is worse. Pairing, MTU exchange, GATT discovery, write, disconnect: every step is radio-on time, every step is current draw, every step is a way the link can fail. For a sensor that wakes up, reads a value, and goes back to sleep, the connection overhead can dwarf the actual data transfer by 10x or more.
BLE 5 extended advertising fixes this. You broadcast up to 1650 bytes connectionlessly, and any scanner that understands extended PDUs picks them up. Networks built around connectionless telemetry listen for extended advertising because it lets devices stay asleep most of the time. Hubble’s terrestrial network is one example.
Here’s the payload story:
Legacy adv: [###] 31 bytes
Adv + scan rsp: [######] 62 bytes
Extended adv: [############################] 254 bytes (single PDU)
Ext adv chain: [#######################################...] 1650 bytesWhat BLE 5 Extended Advertising Actually Gives You
A small header rides on the primary advertising channels (37/38/39). It points listeners to a secondary channel where the real payload lives. That secondary channel can run on a different PHY, and large payloads chain across multiple PDUs.
Primary Channel (37/38/39) Secondary Channel (data)
+----------------------+ +-------------------------+
| AUX_ADV_IND header | --------> | AUX_ADV_IND (payload) |
| (points to secondary)| | + AUX_CHAIN_IND... |
+----------------------+ +-------------------------+
1M PHY 1M / 2M / Coded PHYEach secondary PDU carries up to 254 bytes. Chain them and you get 1650 bytes total, enough for a fat sensor frame, a firmware version manifest, or a batched upload of recent readings.
PHY options matter because the secondary channel can use any of these:
+-----------+----------+------------+-----------------+
| PHY | Symbol | Range | Use case |
+-----------+----------+------------+-----------------+
| 1M | 1 Mbps | Baseline | Default, compat |
| 2M | 2 Mbps | ~0.8x | High throughput |
| Coded S=2 | 500 kbps | ~1.5x | Moderate range |
| Coded S=8 | 125 kbps | ~4x | Long range |
+-----------+----------+------------+-----------------+The primary channel always runs at 1M for backward compatibility. Pick your tradeoff on the secondary.
Prerequisites and ESP-IDF BLE5 menuconfig
You need ESP-IDF v5.x and a chip with BLE 5 support. ESP32-C3, ESP32-S3, and ESP32-C6 all handle 2M and Coded PHY cleanly. The original ESP32 (classic) supports the BLE 5 PDU format but lacks 2M and Coded PHY on older silicon revisions, so check your specific module before committing.
Enable these Kconfig flags:
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_EXT_ADV=y
CONFIG_BT_NIMBLE_MAX_EXT_ADV_INSTANCES=1
CONFIG_BT_NIMBLE_EXT_ADV_MAX_SIZE=1650Turning on BT_NIMBLE_EXT_ADV swaps out some legacy advertising code paths. The old ble_gap_adv_start() route won’t work for extended advertising. You use the ble_gap_ext_adv_* family instead.
If you haven’t set up NimBLE host init and nimble_port_run() yet, get that working with a hello-world legacy advertiser first. This article assumes the host stack is already running.
Walkthrough: Broadcasting a 200-Byte Sensor Payload with esp-idf BLE5
Three steps: configure the instance, set the payload, start advertising.
Configure the extended advertising instance
#include "host/ble_gap.h"
static int gap_event_cb(struct ble_gap_event *event, void *arg) {
/* handle BLE_GAP_EVENT_ADV_COMPLETE etc. */
return 0;
}
void start_ext_adv(void) {
struct ble_gap_ext_adv_params params = {0};
params.connectable = 0; /* connectionless */
params.scannable = 0; /* no scan response */
params.legacy_pdu = 0; /* extended PDU format */
params.primary_phy = BLE_HCI_LE_PHY_1M;
params.secondary_phy = BLE_HCI_LE_PHY_2M;
params.tx_power = 127; /* host picks max */
params.sid = 0;
params.itvl_min = 0x30; /* 30 ms */
params.itvl_max = 0x60; /* 60 ms */
int rc = ble_gap_ext_adv_configure(0, ¶ms, NULL,
gap_event_cb, NULL);
assert(rc == 0);The connectable = 0, scannable = 0, legacy_pdu = 0 triple is what makes this a non-connectable extended advertisement.
Build and set the payload
struct sensor_frame {
uint8_t device_id[6];
uint32_t timestamp;
int16_t temp_c_q8;
int16_t humidity_q8;
int16_t channels[90]; /* 180 bytes of telemetry */
} __attribute__((packed));
struct sensor_frame frame;
populate_frame(&frame); /* your sensor read */
struct os_mbuf *data = os_msys_get_pkthdr(sizeof(frame), 0);
assert(data != NULL);
rc = os_mbuf_append(data, &frame, sizeof(frame));
assert(rc == 0);
rc = ble_gap_ext_adv_set_data(0, data);
assert(rc == 0);NimBLE takes ownership of the mbuf when you call ble_gap_ext_adv_set_data. Don’t free it yourself.
A common mistake: calling ble_gap_ext_adv_set_data before ble_gap_ext_adv_configure returns BLE_HS_EINVAL. The instance has to exist before you hand it data.
Start advertising
rc = ble_gap_ext_adv_start(0, 0, 0);
/* instance=0, duration=0 (forever), max_events=0 (no limit) */
assert(rc == 0);
}Set duration (in 10 ms units) or max_events if you want bounded bursts. For a sensor that wakes every 30 seconds, you’d typically advertise for 100-300 ms then sleep, which keeps average current draw in the tens of microamps even with a relatively chunky payload.
The 30-60 ms advertising interval is reasonable for active broadcast. Slow it down to hundreds of milliseconds if you can tolerate the latency, since it cuts radio-on time roughly linearly.
The Coded PHY Tradeoff
Switch secondary_phy to BLE_HCI_LE_PHY_CODED and you get roughly 4x the range, paid for in airtime. A 200-byte payload on 2M PHY takes about 1 ms over the air. The same payload on Coded S=8 takes around 8 ms, eating 8x the current per advertisement and giving interference and other advertisers eight times the window to clobber you.
Rough guidance:
- 2M PHY: dense indoor, short range, battery-sensitive. Asset trackers in a warehouse, indoor environmental sensors.
- 1M PHY: when you don’t know what scanners will see your packets. Safest default.
- Coded S=2: outdoor or building-wide deployments where range matters more than airtime.
- Coded S=8: long-range outdoor sensors, agriculture, infrastructure monitoring. Use sparingly and with longer advertising intervals.
If you’re targeting a network specifically designed to receive extended advertisements, the receiver usually scans all PHYs in parallel. Custom gateways built on cheaper SoCs may only scan 1M, so check before assuming Coded PHY will reach anything.
Scanner Compatibility Pitfalls
Not every receiver speaks extended advertising. The line is roughly:
- iOS 13+ on iPhone 8 and later: yes.
- Android 8+ on hardware with Bluetooth 5 radios: usually yes, but the Android BLE stack has a long history of weird edge cases. Test on actual target devices.
- nRF Connect on a Bluetooth 5 phone: yes, and this is your debugging tool of choice.
- Older USB BLE dongles (CSR4.0, CC2540, original nRF51): no. They can’t decode extended PDUs at all. The packet is invisible to them.
- Most consumer BLE gateways shipped before ~2019: check the datasheet, assume no.
If you control both transmitter and receiver, none of this matters. If you’re broadcasting to third-party scanners, advertise legacy data in parallel using a second instance, or fall back entirely to legacy mode and accept the 31-byte limit.
Verifying It Works
Open nRF Connect on a recent phone and start a scan with no filters. Your device shows up with a “Bluetooth 5” or “Extended” tag, and the advertised data field shows the full payload (you can copy it as hex and decode against your struct).
In idf.py monitor, the NimBLE host logs the advertising state transitions. A successful start looks something like:
I (1234) NimBLE: GAP procedure initiated: extended advertise; instance=0If you see BLE_HS_EINVAL (error 0x02), nine times out of ten it’s payload-set-before-configure or a Kconfig flag you forgot. If the device advertises but no scanner sees it, you’re probably on Coded PHY with a scanner that only listens on 1M.
Beyond Single-Instance Broadcast
You went from 31 bytes to 1650, dropped the connection overhead entirely, and picked your own range/throughput tradeoff. For sensor telemetry, that’s most of what BLE 5 has to offer.
This approach isn’t good for bidirectional control, large file transfers, or anything that needs acknowledgment. Use L2CAP connection-oriented channels for those.
Worth exploring next: periodic advertising (predictable scan windows, lower scanner power), advertising sets (multiple concurrent payloads with different intervals and PHYs), and the Hubble Device SDK reference applications if you want to see extended advertising integrated into a production firmware structure rather than a standalone example.
Hubble Network turns extended advertising payloads into globally reachable telemetry, no gateways or pairing required. See how it works →