Building reliable BLE firmware involves far more than getting a peripheral to show up in a scanner app. After shipping several battery-powered sensor nodes and gateway products that have to run for months on coin cells, I've learned that the real challenges sit in the details of your GATT design, how you use the limited advertising space, and how defensively you manage connection lifecycles. A GATT structure that looks fine on the bench can fail in the field when phones cache services aggressively, or when iOS and Android negotiate different connection intervals. This article walks through the practical decisions I make on every BLE project, from service definition to connection supervision, with code and tradeoffs drawn from Zephyr and FreeRTOS-based stacks.
Structuring GATT Services, Characteristics and Descriptors for Interoperability
A common mistake I see is treating GATT as a generic data pipe. If you define one service with a dozen opaque characteristics, you lose interoperability and make future firmware updates painful. I design around the Bluetooth SIG's service hierarchy: Service -> Characteristic -> Descriptor, with each characteristic declaring its properties, permissions, and presentation format explicitly. In my experience, starting from an adopted service when one exists saves months of app-side work. For example, use Environmental Sensing (0x181A) or Battery Service (0x180F) before inventing a custom 128-bit UUID alternative.
When you do need a custom service, define a 128-bit base UUID and derive characteristic UUIDs consistently. Keep the count low — each characteristic adds attribute table RAM and increases discovery time. I aim for 3-5 characteristics per custom service, grouping related data and using a single characteristic with a structured payload rather than splitting every field. Remember that the attribute table lives in RAM on many SoCs (Nordic nRF52, ESP32-C3), so a bloated GATT DB can directly limit your heap for the application.
Choosing Properties, Permissions and Data Formats
Properties (read, write, notify, indicate) should match actual usage. If a sensor value only flows from device to phone, expose it as Read + Notify, not Write. Permissions enforce security at the stack level: require encryption for writes that change device state, but allow unauthenticated reads for non-sensitive telemetry if you want to support beacon-like scenarios without pairing. I've found that defining the Characteristic Presentation Format descriptor (0x2904) pays off immediately — it tells the client the unit, exponent, and format, so your iOS or Android app doesn't hard-code parsing logic.
Descriptor choice also matters for client behavior. The Client Characteristic Configuration Descriptor (CCCD, 0x2902) is mandatory for any notifiable characteristic; without it, no stack will enable notifications. The Characteristic User Description (0x2901) is optional but useful during development. Avoid adding proprietary descriptors unless the client truly needs them; they bloat discovery responses.
/* Custom Environmental Service on Zephyr - GATT DB definition
* Based on Zephyr Project Documentation patterns */
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>
#define BT_UUID_CUSTOM_ENV_SVC_VAL \
BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef0)
static struct bt_uuid_128 custom_env_uuid = BT_UUID_INIT_128(
BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef0));
static struct bt_uuid_128 temp_char_uuid = BT_UUID_INIT_128(
BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef1));
static ssize_t read_temp(struct bt_conn *conn, const struct bt_gatt_attr *attr,
void *buf, uint16_t len, uint16_t offset)
{
int16_t temp_centi = 2345; /* 23.45 C - replace with sensor read */
return bt_gatt_attr_read(conn, attr, buf, len, offset, &temp_centi, sizeof(temp_centi));
}
static void temp_ccc_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
bool notif_enabled = (value == BT_GATT_CCC_NOTIFY);
printk("Temp notifications %s\n", notif_enabled ? "enabled" : "disabled");
}
BT_GATT_SERVICE_DEFINE(custom_env_svc,
BT_GATT_PRIMARY_SERVICE(&custom_env_uuid),
BT_GATT_CHARACTERISTIC(&temp_char_uuid.uuid,
BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ_ENCRYPT,
read_temp, NULL, NULL),
BT_GATT_CCC(temp_ccc_cfg_changed,
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE_ENCRYPT),
BT_GATT_CPF(&temp_cpf), /* Presentation format: sint16, 0.01 C units */
);
This pattern enforces encrypted reads and CCC writes, which prevents an unauthenticated central from silently enabling notifications and draining power. For resource-constrained stacks running on FreeRTOS Documentation based ports like NimBLE, the same principles apply but the API is C callbacks rather than macros — the design intent remains identical.
Crafting BLE Advertising Payloads Within the 31-Byte Limit
Advertising is your only chance to be discovered, and you have 31 bytes in the legacy ADV_IND payload (or 255 with extended advertising on BLE 5.0+). I treat this as a strict budget. Every AD structure has a length byte + type byte overhead, so a 16-bit Service UUID list costs 4 bytes even for one UUID, and a complete local name can consume the entire packet. In my experience, the most reliable payload for a connectable peripheral contains: Flags (3 bytes), Service UUID (4-6 bytes), and Manufacturer Specific Data or Service Data (remaining bytes). Leave the local name to the scan response if you must include it.
Undirected connectable advertising (ADV_IND) is the default, but consider ADV_NONCONN_IND for pure beacons and ADV_DIRECT_IND only for reconnection to a bonded central — it is not for general discovery. Advertising interval directly impacts power and discovery latency. I typically start at 100 ms during provisioning for fast discovery, then back off to 500 ms to 1 s in normal operation. Going below 20 ms violates the spec and drains the battery for little gain; most phones scan with windows that will catch a 500 ms advertiser within 2-3 seconds anyway.
Encoding Sensor Data Efficiently in Manufacturer Data
If you need to broadcast telemetry without a connection, pack it tightly. Use little-endian encoding, scale floats to integers, and include a sequence number to detect missed packets. Avoid JSON or ASCII in advertisements — it's wasteful and forces the scanner to parse strings. I also include a 1-byte capability flag that tells the gateway what services are available without requiring a full GATT discovery, which helps when you have thousands of nodes and want to filter at the scanner.
/* Zephyr advertising data setup - fitting within 31 bytes */
#include <zephyr/bluetooth/bluetooth.h>
#define COMPANY_ID_NORDIC 0x0059
/* Payload: Flags (3) + Service UUID (4) + Manufacturer Data (10) = 17 bytes total */
static const struct bt_data ad[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA_BYTES(BT_DATA_UUID16_ALL,
BT_UUID_16_ENCODE(BT_UUID_BAS_VAL),
BT_UUID_16_ENCODE(BT_UUID_ESS_VAL)),
BT_DATA(BT_DATA_MANUFACTURER_DATA, mfg_data, sizeof(mfg_data)),
};
/* mfg_data layout: [company_id:2][seq:1][temp:2][hum:2][batt:1][flags:1][rsv:1] */
static uint8_t mfg_data[10] = {0};
static uint8_t scan_rsp_data[] = { /* Local name in scan response, not main ADV */
};
void build_advertising_payload(int16_t temp, uint16_t hum, uint8_t batt_pct)
{
static uint8_t seq = 0;
mfg_data[0] = COMPANY_ID_NORDIC & 0xFF;
mfg_data[1] = (COMPANY_ID_NORDIC >> 8) & 0xFF;
mfg_data[2] = seq++;
mfg_data[3] = temp & 0xFF;
mfg_data[4] = (temp >> 8) & 0xFF;
mfg_data[5] = hum & 0xFF;
mfg_data[6] = (hum >> 8) & 0xFF;
mfg_data[7] = batt_pct;
mfg_data[8] = 0x01; /* flags: connectable, battery ok */
}
void start_advertising(void)
{
bt_le_adv_start(BT_LE_ADV_CONN_NAME, ad, ARRAY_SIZE(ad),
scan_rsp_data, ARRAY_SIZE(scan_rsp_data));
/* Interval controlled via bt_le_adv_param: .interval_min = 0x320 = 500ms */
}
One hard lesson: Android 12+ and iOS both throttle scanning heavily in the background. If your design depends on a phone reliably seeing every advertisement, you will be disappointed. For gateway architectures, I prefer dedicated scanners using continuous passive scanning rather than relying on phones. This is similar to tradeoffs I weigh when choosing broader CoAP vs MQTT: Choosing the Right IoT Protocol for Constrained Devices — push reliability against power and complexity.
Managing Connection Parameters, Interval Negotiation and Supervision Timeouts
Once connected, your power consumption and responsiveness are dictated by three parameters: connection interval, peripheral latency, and supervision timeout. The central proposes them, but the peripheral can request an update. I never accept the phone's default blindly. iOS often proposes 30 ms intervals, which is responsive but expensive; Android varies widely by vendor. For a sensor that reports every 5 seconds, I request 100-150 ms interval with latency 4, which lets the peripheral skip up to 4 connection events and sleep longer.
Negotiation is not instantaneous. After connection, wait at least 1-2 seconds before requesting an update, or the central will reject it. If the request fails, back off exponentially — I've seen peripherals get disconnected for spamming parameter update requests. Always validate that interval_min ≤ interval_max and that supervision timeout > (1 + latency) * interval_max * 2. The stack will disconnect you if you violate this relationship. The Zephyr Project Documentation provides clear guidance on the BT_CONN_LE_PARAM macros and the required timing.
Supervision timeout is your link-loss detector. Set it too short and brief RF fades cause disconnects; too long and you waste power maintaining a dead link. For indoor battery sensors I use 4 seconds, for wearables where link loss should be detected quickly I use 2 seconds. Remember the timeout is expressed in units of 10 ms, and many stacks enforce a minimum of 100 (1 second).
| Deployment Profile | Interval | Peripheral Latency | Supervision Timeout | When to Use |
|---|---|---|---|---|
| High Throughput (FW update) | 7.5 - 15 ms | 0 | 2000 ms | OTA, bulk transfer; draws ~8-12 mA average |
| Interactive (HID / UART) | 20 - 30 ms | 0 | 2000 ms | Responsive UX, moderate power |
| Low Power Sensor | 100 - 200 ms | 4 - 10 | 4000 - 6000 ms | Battery nodes reporting every few seconds |
| Ultra-Low Power Beacon | 1000 - 4000 ms* | 0 | 6000 ms | Periodic connection only; *requires interval extension |
The ultra-low power row often requires Connection Subrating (BLE 5.3) or accepting that you will disconnect and rely on advertising between infrequent connections. I prefer to stay connected with high latency for sensors and disconnect completely for beacons that only need occasional configuration writes.
Implementing Robust GATT Client Caching and Service Discovery
Service discovery is expensive in time and energy. A full discovery over a 30 ms connection can take 1-2 seconds and dozens of transactions. Clients should cache the attribute handle-to-UUID mapping after the first discovery and use the Service Changed characteristic (0x2A05) to invalidate that cache. As a peripheral developer, you must increment the Database Hash and indicate Service Changed when your GATT DB changes after a firmware update — otherwise bonded phones will use stale handles and reads will return the wrong characteristic.
In my experience, the most common field failure is not handling discovery failures gracefully. If the central discovers services before pairing is complete, reads on encrypted characteristics will return an Insufficient Authentication error. Your client code must bond, then rediscover or retry. On the peripheral side, do not assume the central will discover in order; some Android stacks discover all services in parallel, generating concurrent ATT requests that can overrun a small ATT MTU.
MTU Negotiation and Long Reads
Default ATT MTU is 23 bytes, leaving just 20 bytes for payload after opcode and handle. Always attempt MTU exchange to 247 or 512 early in the connection — it dramatically improves throughput for notifications. For characteristics larger than MTU-3, use long reads (Prepare Write / Execute Write for reliable writes) or chunked notifications. I implement a simple fragmentation protocol for payloads over 200 bytes: a header characteristic indicates total length, followed by notified fragments with offset, rather than relying on long characteristic values which many stacks handle poorly.
/* Connection management - parameter update and MTU exchange (Zephyr) */
#include <zephyr/bluetooth/conn.h>
static struct bt_conn_le_param slow_sensor_params = {
.interval_min = BT_GAP_PERIPH_PREF_MIN_INT * 8, /* ~100 ms */
.interval_max = BT_GAP_PERIPH_PREF_MAX_INT * 8, /* ~120 ms */
.latency = 4,
.timeout = 400, /* 400*10ms = 4000 ms */
};
static void connected(struct bt_conn *conn, uint8_t err)
{
if (err) return;
/* Delay parameter update - do not request immediately */
k_work_schedule(¶m_update_work, K_MSEC(1500));
/* Request larger MTU - central will respond, negotiated MTU is min(request, central) */
int mtu_err = bt_gatt_exchange_mtu(conn, NULL);
if (mtu_err) {
printk("MTU exchange failed: %d\n", mtu_err);
}
}
static void param_update_work_handler(struct k_work *work)
{
int err = bt_conn_le_param_update(current_conn, &slow_sensor_params);
if (err == -EALREADY) {
/* Already at requested params or update in progress */
} else if (err) {
/* Back off - retry after 5s, do not spam */
k_work_schedule(¶m_update_work, K_MSEC(5000));
}
}
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
.le_param_updated = on_params_updated,
};
For gateway applications that maintain connections to 8-10 peripherals simultaneously, I keep a persistent cache keyed by peer identity address and Database Hash. This cuts reconnection time from ~1.5 s to under 200 ms. If you are bridging BLE to IP, the choice of upstream protocol matters — I've used MQTT Protocol Deep Dive: QoS Levels, Retained Messages and Session Management to forward GATT notifications with QoS 1 when delivery confirmation is needed versus QoS 0 for high-rate telemetry.
Handling Security, Pairing and Attribute Permissions in Production Firmware
Security in BLE is not optional for anything that actuates or exposes personal data. LE Secure Connections with ECDH (BLE 4.2+) should be your baseline; Legacy Pairing with TK is vulnerable to passive eavesdropping. I enforce LE Secure Connections, use the highest IO capability the hardware allows, and require bonding (long-term key storage) rather than transient pairing. For headless sensors without display or keyboard, use Just Works with strong application-layer authentication, but understand that this does not protect against MITM — document that risk.
Attribute permissions must align with your pairing strategy. Set BT_GATT_PERM_READ_ENCRYPT | BT_GATT_PERM_WRITE_ENCRYPT for sensitive characteristics, and BT_GATT_PERM_READ_AUTHEN if you need MITM protection. Do not rely on hiding characteristics; any scanner can discover them. All authorization checks belong in the attribute callbacks, not just in the app logic. I also set the Security Mode 1 Level 3 or 4 requirement at the service level when the entire service is sensitive, which simplifies reasoning about access.
Bonding, Key Distribution and Privacy
Store Long Term Keys (LTK), Identity Resolving Keys (IRK), and Connection Signature Resolving Keys (CSRK) in secure flash with wear leveling. I've debugged devices that lost bonds after 1000 write cycles because keys were stored in a raw flash page without relocation. Enable privacy with Resolvable Private Addresses (RPA) to prevent tracking; rotate the RPA every 15 minutes as recommended. When a peripheral uses RPA, the central must have its IRK to resolve it — test this with at least two different phone models, because some Android vendors cache IRKs incorrectly.
Pairing failures in the field are often due to timeouts. Users take longer than 30 seconds to confirm a passkey. Extend your pairing timeout and provide clear LED or display feedback. After bonding, verify that encryption is actually enabled before allowing writes to critical characteristics — I've seen stacks report "connected" before encryption completes, leading to a race where a write is rejected and the app shows a spurious error.
Debugging Throughput Bottlenecks and Power Tradeoffs in BLE Links
Throughput on BLE is fundamentally limited by interval, MTU, and the number of packets per connection event. With a 7.5 ms interval, 247-byte MTU, and 4 packets per event, you can approach ~1.3 Mbps application throughput under ideal conditions, but realistic sensor designs achieve 50-200 kbps. If you need more, consider LE 2M PHY (BLE 5.0) which doubles the symbol rate — but both sides must support it, and range decreases. I negotiate 2M PHY for OTA updates and fall back to 1M PHY for normal operation to preserve range.
Power profiling should be done with an actual current probe, not stack estimates. I've measured 3.5 mA average at 100 ms interval with latency 4 versus 12 mA at 15 ms interval with no latency on an nRF52840, which translates to months versus weeks on a 220 mAh coin cell. Advertising dominates power if you advertise continuously; a 1 s interval costs ~15 µA average, while 100 ms costs ~150 µA. Duty-cycle your advertising when not in provisioning mode — advertise aggressively for 30 seconds after boot or button press, then stop or slow to 1 s.
Debugging tools that have saved me: the Nordic nRF Sniffer with Wireshark for ATT-level traces, Zephyr's btmon for HCI logs, and a simple RSSI histogram in firmware to detect antenna detuning. When comparing BLE to other IoT radios for a product, I evaluate range and topology alongside power — for long-range sensor backhaul I often choose LoRaWAN instead, and the tradeoffs described in LoRaWAN Network Architecture: Gateways, Network Server and Join Procedures are a useful parallel to BLE's star topology limitations. BLE is excellent for short-range, high-interaction devices; it is not a replacement for mesh or LPWAN when you need kilometers of range.
Final practical checklist I run before release: verify GATT DB survives firmware updates with Service Changed indications, test with at least three central stacks (iOS, Samsung Android, stock Android), measure connect-and-discover time with bonded and unbonded peers, and run a 48-hour connection stability test with supervision timeout and interference. The Zephyr Project Documentation and Nordic infocenter examples provide solid baselines, but field testing with real phones in RF-noisy environments reveals issues no simulator will catch.
Frequently Asked Questions
How many GATT services and characteristics should a BLE peripheral expose?
Keep it minimal. I recommend one custom service with 3-5 characteristics plus any adopted SIG services you need (Battery, Device Information). Each additional characteristic increases the attribute table size, discovery time, and RAM usage. Group related fields into a structured characteristic rather than creating a separate characteristic per field. If you find yourself needing more than 10 characteristics total, reconsider your data model — you may be exposing internal state that should be aggregated.
Why do my BLE notifications stop working after a phone reconnects?
This is almost always a CCCD or caching issue. Notifications require the client to write 0x0001 to the CCCD handle after each connection — the peripheral must not assume the previous CCCD value is retained unless you have bonded and restored it. Also verify you are not using stale handles from a cached discovery; if your GATT DB changed after a firmware update without indicating Service Changed, the phone will write to the wrong handle. Check that your database hash is updated and that you send a Service Changed indication to bonded peers on boot if the DB has changed.
What connection interval should I request for a battery-powered sensor?
For sensors reporting every few seconds, request 100-150 ms with latency 4-6 and a 4-second supervision timeout. This lets the peripheral skip connection events and sleep, drawing roughly 3-5 mA average versus 10+ mA at 15-30 ms intervals. Wait 1-2 seconds after connection before requesting the update, and handle rejections gracefully with exponential backoff. Always ensure supervision timeout > (1+latency)*interval*2, otherwise the link will supervise out unexpectedly.
Can I fit sensor data and a device name in the same 31-byte advertisement?
Rarely, and you should avoid trying. Flags (3 bytes) + complete local name of 10 characters (12 bytes) + service UUID list (4+ bytes) already consumes most of the budget. Put the local name in the scan response packet, which is sent on request and also provides 31 bytes, and use the primary advertisement for service UUIDs and manufacturer data. If you need extended advertising (BLE 5.0, up to 255 bytes) both central and peripheral must support it — most phones do, but many legacy gateways do not.