BLE MTU Negotiation Explained: How to Send More Data Per Packet

Your notifications max out at 20 bytes. You set up the connection, you fire off your sensor data, and somehow you’re crawling at 2 KB/s when the datasheet promised 10x more. Pull up a sniffer and there it is: ATT MTU stuck at 23. The exchange either never happened, happened too late, or got rejected, and now every notification is paying a 3-byte header tax on a 20-byte payload.
This article covers what BLE MTU negotiation actually does, how to configure it correctly in Nordic nRF Connect SDK and Zephyr, and the timing and mobile OS gotchas that silently cap throughput in shipped products. I’m assuming you’re comfortable with GATT and connection events.
What MTU Actually Means in BLE
Three “MTU"s show up in BLE conversations, and conflating them will burn an afternoon:
- ATT_MTU: the maximum size of an ATT PDU. This is the one you negotiate. Default 23 bytes.
- L2CAP MTU: maximum L2CAP SDU size. For classic ATT, it tracks ATT_MTU.
- Link layer PDU: the actual radio packet payload. Controlled by DLE, not MTU. Don’t mix these up.
Default ATT_MTU is 23 bytes. Three of those go to the ATT opcode and attribute handle, leaving 20 bytes of usable payload for a notification or write-without-response. That’s your ceiling until you negotiate higher.
+--------+----------------------+
| ATT Hdr| Attribute Value |
| 3 B | up to MTU - 3 B |
+--------+----------------------+
ATT_MTU (default 23)Most stacks cap ATT_MTU at 247 bytes (the spec allows 517). 247 is the common sweet spot because it fits cleanly inside a DLE-extended link layer PDU of 251 bytes. Bump to 247 and a single notification carries 244 bytes of payload instead of 20.
How MTU Negotiation Works
The exchange itself is dead simple: one ATT Exchange MTU Request, one Response, done.
Central Peripheral
|---- Exchange MTU Req --->|
| (Client RX MTU=247) |
|<--- Exchange MTU Rsp ----|
| (Server RX MTU=247) |
| Negotiated = min(247,247)|The negotiated value is min(client_rx_mtu, server_rx_mtu). Both sides apply it immediately after the response.
Two rules trip people up:
- One exchange per connection. Whoever speaks first locks in the MTU. A second Exchange MTU Request returns Request Not Supported.
- Either side can initiate, but in practice the central does. iOS and Android both initiate automatically. If you’re writing peripheral firmware, assume you won’t get to initiate, because by the time you try, the central has already done it.
The exchange must complete before you start pushing large notifications. Anything sent during the window between connection and MTU response goes out at 23 bytes, regardless of what gets negotiated 10ms later.
Configuring MTU in Nordic nRF Connect SDK / Zephyr
Three Kconfig options. Set them together or you’ll get truncation or asserts:
CONFIG_BT_L2CAP_TX_MTU=247
CONFIG_BT_BUF_ACL_RX_SIZE=251
CONFIG_BT_BUF_ACL_TX_SIZE=251The ACL buffers need to be MTU + 4 (L2CAP header). Raising CONFIG_BT_L2CAP_TX_MTU without raising the ACL buffer sizes is the most common silent failure mode in Zephyr BLE projects.
On the peripheral side, register a callback so you know when MTU is actually negotiated. Don’t assume, verify:
static void mtu_updated(struct bt_conn *conn, uint16_t tx, uint16_t rx)
{
LOG_INF("MTU updated: tx %u, rx %u", tx, rx);
}
static struct bt_gatt_cb gatt_callbacks = {
.att_mtu_updated = mtu_updated,
};
bt_gatt_cb_register(&gatt_callbacks);If you’re writing the central, initiate the exchange right after connection. Don’t wait for service discovery:
static void exchange_func(struct bt_conn *conn, uint8_t err,
struct bt_gatt_exchange_params *params)
{
LOG_INF("MTU exchange %s", err ? "failed" : "done");
}
static struct bt_gatt_exchange_params exchange_params;
exchange_params.func = exchange_func;
bt_gatt_exchange_mtu(conn, &exchange_params);On legacy Nordic SoftDevice projects (nRF5 SDK, not nRF Connect SDK), the equivalent is sd_ble_gattc_exchange_mtu_request(). Same idea, different API. If you’re targeting Hubble’s stack, the Hubble Device SDK terrestrial API overview covers the BLE advertising path, and there’s a Nordic SoftDevice reference application you can clone as a starting point.
The Throughput Picture: MTU + DLE + PHY 2M
MTU alone won’t get you to 1 Mbps of application data. There are three levers, and they have to be tuned together:
Lever | Layer | Effect
----------+-------------+------------------------
ATT MTU | ATT | Bytes per GATT op
DLE | Link Layer | Bytes per radio packet
PHY 2M | PHY | Symbol rate (1->2 Mbps)If ATT MTU is 247 but DLE is stuck at the default 27, every notification fragments. Each fragment costs you a link layer header and an interframe gap. Throughput tanks. PHY 2M doubles the raw radio rate but does nothing if you’re still sending 20-byte ATT payloads. The three combined (MTU 247, DLE 251, PHY 2M) are what get you to the ~1.4 Mbps practical throughput numbers Nordic quotes.
For BLE data throughput in production, set all three explicitly. Don’t rely on stack defaults, and don’t rely on the central to negotiate any of them favorably.
Gotchas and Troubleshooting
Gotcha 1: Negotiation timing. If you start sending notifications before MTU exchange completes, those notifications go out at MTU 23. Even if the exchange completes 5ms later and att_mtu_updated fires with 247, the in-flight packets are already on the air at the old size. Gate any bulk transfer on the att_mtu_updated callback. Don’t kick off a firmware OTA in your connection callback.
Gotcha 2: Only one side initiates. If your peripheral firmware calls bt_gatt_exchange_mtu() and the central has already initiated, you get Request Not Supported. Pick a policy per role and stick to it. Peripherals: don’t initiate. Centrals: always initiate, immediately after connection.
Gotcha 3: iOS caps you. iOS negotiates MTU automatically shortly after connection. Recent iOS versions land at 185 bytes (so 182 bytes of payload). You can’t force it higher, no setting, no entitlement, nothing. If iOS is a target platform, design your application protocol around 182-byte chunks. Don’t assume 244.
Gotcha 4: Android needs the app to ask. Android exposes BluetoothGatt.requestMtu() to app developers. If the app never calls it, you’re stuck at 23 even though Android supports up to 517. Tell the app team explicitly: call requestMtu(247) right after onServicesDiscovered(). Reconnects can also re-trigger MTU negotiation on Android, so make sure your peripheral handles att_mtu_updated firing more than once per device.
Watch your buffers. Raising CONFIG_BT_L2CAP_TX_MTU without raising CONFIG_BT_BUF_ACL_RX_SIZE and CONFIG_BT_BUF_ACL_TX_SIZE causes either silent truncation or, on debug builds, asserts deep in the host stack. Always match: ACL buffer = MTU + 4.
Gotcha 6: Verify with a sniffer. Logs lie. Stack callbacks lie too, sometimes (they report what the stack thinks, not what hit the air). Use the nRF Sniffer with Wireshark, filter on btatt.opcode == 0x02 (Exchange MTU Request) and btatt.opcode == 0x03 (Exchange MTU Response), and read the actual values off the wire. If you don’t see the exchange, it didn’t happen.
Quick Checklist
[ ] Set CONFIG_BT_L2CAP_TX_MTU and matching ACL buffers
[ ] Register att_mtu_updated callback
[ ] Gate large transfers on negotiated MTU
[ ] If central: call bt_gatt_exchange_mtu() post-connect
[ ] If peripheral: test with iOS AND Android, not just nRF Connect app
[ ] Verify with sniffer, not just logs
[ ] Tune DLE and PHY 2M alongside MTUNext: DLE and PHY 2M
MTU is the easiest throughput win in BLE, and it’s also the setting that’s most commonly misconfigured in shipped firmware. Get it right, verify it with a sniffer, and design your application protocol around the worst-case MTU your target platforms allow (usually iOS at 182 bytes of payload).
Once MTU is sorted, the next bottlenecks are DLE and PHY selection. Both deserve their own deep dives, and both compound with MTU. Tune all three together, or you’re leaving most of the throughput on the table.
Hubble Network handles BLE throughput tuning—MTU, DLE, and PHY negotiation—across millions of devices without per-platform firmware tweaks. See how it works →