OTA Firmware Update over BLE from iOS: The Complete Guide (2026)
Apple ships no first-party example. How OTA firmware update over BLE works from a Swift iOS client to a generic peripheral: protocol design, MTU math, the back-pressure bug, and the CRC handshake.
Apple ships no first-party example. The public internet’s best answers are Nordic-specific or 5 years out of date. Here’s how OTA firmware update over BLE works from a Swift iOS client to a generic peripheral: the protocol design, the MTU math, the back-pressure bug that breaks every naive implementation, and the CRC handshake that makes it reliable.
If you ship hardware with a companion iOS app, OTA isn’t optional: customers expect their smart bulb / fitness tracker / smart lock to update itself, not be tethered to a PC. Apple’s CoreBluetooth docs contain zero examples of OTA-over-BLE. The official samples stop at “scan and connect.”
This post walks through:
- Why OTA-over-BLE is necessary (and why Apple is silent)
- The protocol design choices: write-with-response vs without, chunk size, ack patterns
- The MTU negotiation gotcha (most implementations get this wrong)
- CRC32: which polynomial, and why
- The write-without-response back-pressure bug that breaks every naive implementation
- Step-by-step iOS implementation
- ESP-IDF firmware example (server side)
- Real-world throughput numbers (BLE 4.2 vs BLE 5)
- The five recurring bugs and how to debug them
All code samples are vanilla CoreBluetooth (Swift) and vanilla ESP-IDF (C), reproducible without buying anything. At the end I’ll show how Antenna collapses the iOS side into one method call, in case you’d rather skip the typing.
Why OTA over BLE, and why Apple is silent
Hardware ships with bugs. Firmware ships with bugs. The first version of your smart device’s firmware will have something you need to fix in the field. The options are:
- USB tether: customer plugs device into a PC, runs a tool, flashes new firmware. Hardware companies abandoned this around 2014; modern customers don’t tolerate it.
- WiFi OTA: customer’s device joins their WiFi, downloads firmware from your CDN. Great when it works, but the setup UX is terrible, it breaks behind captive portals, and it requires WiFi capability on the chip (costs $1–2 more per unit).
- BLE OTA: customer’s app pushes firmware to the device over the existing BLE connection. Works for any device that ships with BLE (now ~all consumer hardware). Lower bandwidth than WiFi but more than fast enough for typical firmware images (~50–500 KB).
BLE OTA is the dominant pattern for 2026 consumer hardware. Every smart lock, fitness tracker, and earbud you own does it.
So why does Apple ship no example? Three reasons:
- OTA protocols are device-specific. There’s no IETF standard for “stream bytes to a BLE peripheral and have it self-update.” Every vendor invents their own framing. Apple can’t ship “the canonical OTA example” because there isn’t one.
- The hard parts are at the firmware side, which Apple doesn’t ship. The iOS side is “write bytes to a characteristic in chunks.” Sounds simple, isn’t.
- Apple’s own products (AirPods, Apple Watch, HomePod) use private, signed-firmware OTA paths that don’t go through CoreBluetooth at all. There’s no internal example for them to clean up and publish.
So you’re on your own. Here’s the playbook.
Protocol design: the choices you have to make
Every OTA protocol over BLE answers four questions:
Q1: Write-with-response or write-without-response?
| With-response | Without-response | |
|---|---|---|
| Per-chunk speed | ~30–80 ms (waits for ATT ack) | ~2–8 ms (fire-and-forget) |
| Reliability | iOS guarantees delivery + reports errors | Drop-prone; you need your own ack mechanism |
| Throughput on a 200KB image | ~30–80 seconds | ~3–10 seconds |
| Memory pressure on firmware | Low (one chunk at a time) | Higher (firmware buffers incoming bytes) |
| Use when | Small images, you want simple code | Large images, you’ll add a CRC handshake |
For any image > 50 KB, use write-without-response with a CRC handshake at the end. The 10x throughput gain is the difference between a 5-second update and a 50-second update. Users notice.
Q2: Per-chunk acks or end-of-transfer-only verify?
| Per-chunk acks | End-only CRC | |
|---|---|---|
| Resume-on-disconnect | Yes (you know exactly which chunks landed) | No (must restart) |
| Throughput | Lower (waits per chunk) | Higher |
| Firmware complexity | Higher (notify on every chunk) | Lower |
| Use when | Connection is flaky, image > 500 KB | Stable indoor connection, image < 200 KB |
For a v1 OTA implementation against a stable connection, end-only CRC is enough. Resume-on-disconnect is a v2 feature. Most consumer firmware OTAs do not implement chunk-level resume.
Q3: One characteristic or two?
| One characteristic | Two characteristics | |
|---|---|---|
| Setup | Single GATT char with write+notify | Separate OTA Data (write) + OTA Control (write+notify) |
| Clarity | All bytes mixed on one wire | Clear separation: data on one, commands on another |
| Buyer DX | Confusing protocol framing | Clean |
Use two. The minor cost in service-tree complexity pays back immediately in code clarity. The pattern: stream firmware bytes to OTA Data, send commands (start, commit, abort) to OTA Control, receive acks on OTA Control notifications.
Q4: Plain bytes or include a header per chunk?
| Plain bytes | Framed chunks | |
|---|---|---|
| Wire format | Just firmware bytes | seq-number + length + bytes per chunk |
| Resume-on-disconnect | Impossible | Possible (firmware knows which seq numbers it got) |
| Throughput | Higher (no per-chunk overhead) | Slightly lower |
| Complexity | Minimal | Moderate |
For v1: plain bytes. Firmware tracks bytesReceived (just append-and-count). Add framing only when you need resume.
The MTU negotiation gotcha
This is where most naive implementations silently fail.
What the spec says
The default BLE ATT MTU is 23 bytes. After 3 bytes of ATT header overhead, you can write 20 bytes per packet. At 20 bytes per packet with ~5 ms between packets, your max throughput is ~4 KB/s. A 200 KB firmware = 50 seconds.
What iOS actually does
iOS automatically negotiates up to MTU 185 on most peripherals starting in iOS 10, and up to MTU 247 on supported peripherals starting in iOS 11. At MTU 247 (244-byte payload), throughput jumps to ~60 KB/s, which is 15x faster.
What the firmware has to do
The firmware must accept the MTU upgrade request from iOS. ESP-IDF defaults to 23 (the spec minimum). Add this to your firmware init:
// ESP-IDF v5.x + NimBLE
ble_att_set_preferred_mtu(247);
Or for Bluedroid:
esp_ble_gatt_set_local_mtu(247);
Without this, iOS will offer MTU 247, the firmware will silently negotiate down to 23, and you’ll wonder why throughput is 4 KB/s in production.
How to query the actual negotiated MTU from iOS
let writeMTU = peripheral.maximumWriteValueLength(for: .withoutResponse)
print("Negotiated MTU: \(writeMTU + 3) bytes (\(writeMTU) byte payload)")
Returns the current negotiated MTU minus the 3-byte ATT header. This is the right chunk size for writeValue(_, for:, type: .withoutResponse). Query after the connection is established and after a brief delay (~100 ms) to let MTU negotiation complete.
For write-with-response, max is 512 bytes (BLE spec maximum for ATT writes). Different limit, different field:
let withResponseMTU = peripheral.maximumWriteValueLength(for: .withResponse)
CRC32 — which polynomial?
There are dozens of CRC32 variants. For BLE OTA, the de-facto standard is IEEE 802.3 (Ethernet / zlib) with polynomial 0xEDB88320 (reflected). This matches:
zlib.crc32in PythonCRC32.update()in Java- ESP-IDF’s
esp_crc32_le() - Most embedded toolchains
Swift implementation:
func crc32(_ data: Data) -> UInt32 {
var crc: UInt32 = 0xFFFFFFFF
for byte in data {
crc ^= UInt32(byte)
for _ in 0..<8 {
crc = (crc & 1 != 0) ? (crc >> 1) ^ 0xEDB88320 : crc >> 1
}
}
return crc ^ 0xFFFFFFFF
}
Test vectors (verify your impl against these):
| Input | CRC32 |
|---|---|
Data() (empty) | 0x00000000 |
"hello world".utf8 | 0x0D4A1185 |
Data([0x00]) | 0xD202EF8D |
Data([0x01, 0x02, 0x03]) | 0x55BC801D |
Don’t invent a custom polynomial. Use IEEE 802.3 so your iOS-side CRC matches whatever your embedded toolchain ships.
The write-without-response back-pressure bug
This is the bug that breaks every naive OTA implementation. Symptoms:
- Works in dev with firmware < 10 KB
- Breaks at 50 KB+, with the firmware reporting “incomplete image”
- Throughput inexplicably tanks
- iOS console shows “writeWithoutResponse dropped”
Cause: iOS buffers a small number of write-without-response packets in the L2CAP layer. When the buffer is full, subsequent writes are silently dropped. iOS doesn’t throw and doesn’t call your delegate. The bytes just disappear.
The fix
Apple added the canSendWriteWithoutResponse property and the peripheralIsReady(toSendWriteWithoutResponse:) delegate callback in iOS 11. You must check the flag before every write, and wait for the callback when it’s false.
// Before each chunk:
if !peripheral.canSendWriteWithoutResponse {
// Wait for the delegate callback
await withCheckedContinuation { continuation in
self.pendingReadyContinuation = continuation
}
}
peripheral.writeValue(chunk, for: characteristic, type: .withoutResponse)
// In CBPeripheralDelegate:
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
pendingReadyContinuation?.resume()
pendingReadyContinuation = nil
}
Note: this bug doesn’t exist for .withResponse writes; those wait naturally for ATT ack.
If you skip this fix: your OTA “works” for small images (the buffer is never full) and “mysteriously breaks” for production-sized images. This bug is the single largest source of “OTA almost works” support tickets in the wild.
Step-by-step iOS implementation
Putting it all together. Pure CoreBluetooth, no kit dependency:
import CoreBluetooth
final class OTAClient: NSObject {
private let peripheral: CBPeripheral
private let otaData: CBCharacteristic
private let otaControl: CBCharacteristic
private var ackContinuation: CheckedContinuation<Data, Error>?
private var readyContinuation: CheckedContinuation<Void, Never>?
init(peripheral: CBPeripheral, otaData: CBCharacteristic, otaControl: CBCharacteristic) {
self.peripheral = peripheral
self.otaData = otaData
self.otaControl = otaControl
super.init()
peripheral.delegate = self
peripheral.setNotifyValue(true, for: otaControl)
}
func performOTA(firmware: Data) async throws {
let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse)
let crc = crc32(firmware)
// 1. Send START frame: 0x01 || size_LE32 || crc_LE32
var startFrame = Data([0x01])
var sizeLE = UInt32(firmware.count).littleEndian
var crcLE = crc.littleEndian
startFrame.append(Data(bytes: &sizeLE, count: 4))
startFrame.append(Data(bytes: &crcLE, count: 4))
try await write(startFrame, to: otaControl, type: .withResponse)
let startAck = try await waitForAck()
guard startAck == Data([0x81, 0x00]) else {
throw OTAError.startRejected(startAck)
}
// 2. Stream firmware in MTU-sized chunks via write-without-response
for offset in stride(from: 0, to: firmware.count, by: mtu) {
let end = min(offset + mtu, firmware.count)
let chunk = firmware.subdata(in: offset..<end)
// The back-pressure check that nobody talks about:
if !peripheral.canSendWriteWithoutResponse {
await withCheckedContinuation { continuation in
self.readyContinuation = continuation
}
}
peripheral.writeValue(chunk, for: otaData, type: .withoutResponse)
}
// 3. Send COMMIT frame: 0x02 || crc_LE32 (write-with-response also flushes
// queued no-response writes)
var commitFrame = Data([0x02])
commitFrame.append(Data(bytes: &crcLE, count: 4))
try await write(commitFrame, to: otaControl, type: .withResponse)
let commitAck = try await waitForAck()
guard commitAck == Data([0x82, 0x00]) else {
throw OTAError.verifyFailed(commitAck)
}
}
private func write(_ data: Data, to char: CBCharacteristic, type: CBCharacteristicWriteType) async throws {
try await withCheckedThrowingContinuation { continuation in
// ... (omitted: standard pending-write continuation pattern)
}
}
private func waitForAck() async throws -> Data {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
self.ackContinuation = continuation
}
}
}
extension OTAClient: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard characteristic.uuid == otaControl.uuid,
let value = characteristic.value else { return }
ackContinuation?.resume(returning: value)
ackContinuation = nil
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
readyContinuation?.resume()
readyContinuation = nil
}
}
enum OTAError: Error {
case startRejected(Data)
case verifyFailed(Data)
}
That’s ~70 lines for a working v1 OTA client. Most public examples skip the back-pressure check and silently break at scale.
ESP-IDF firmware example (server side)
NimBLE-based ESP-IDF v5.x. The OTA Data + OTA Control characteristics live under a custom service:
// Register the OTA characteristics (inside your GATT service definition)
static const struct ble_gatt_chr_def ota_chars[] = {
{
.uuid = BLE_UUID128_DECLARE(/* OTA Data UUID bytes */),
.access_cb = ota_data_write_cb,
.flags = BLE_GATT_CHR_F_WRITE_NO_RSP,
},
{
.uuid = BLE_UUID128_DECLARE(/* OTA Control UUID bytes */),
.access_cb = ota_control_cb,
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY,
.val_handle = &ota_control_handle,
},
{ 0 }
};
// State
static const esp_partition_t *update_partition;
static esp_ota_handle_t ota_handle;
static uint32_t expected_size;
static uint32_t expected_crc;
static uint32_t bytes_received;
// OTA Control write callback (start / commit / abort)
static int ota_control_cb(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
uint8_t *data = ctxt->om->om_data;
uint16_t len = OS_MBUF_PKTLEN(ctxt->om);
if (len < 1) return BLE_ATT_ERR_INVALID_PDU;
switch (data[0]) {
case 0x01: { // START
if (len < 9) return BLE_ATT_ERR_INVALID_PDU;
expected_size = *(uint32_t *)(data + 1);
expected_crc = *(uint32_t *)(data + 5);
bytes_received = 0;
update_partition = esp_ota_get_next_update_partition(NULL);
esp_err_t err = esp_ota_begin(update_partition, expected_size, &ota_handle);
uint8_t reply[2] = {0x81, err == ESP_OK ? 0x00 : 0xFF};
send_notification(conn_handle, ota_control_handle, reply, 2);
return 0;
}
case 0x02: { // COMMIT
if (len < 5) return BLE_ATT_ERR_INVALID_PDU;
uint32_t client_crc = *(uint32_t *)(data + 1);
uint8_t reply[2] = {0x82, 0x00};
if (bytes_received != expected_size) reply[1] = 0x02;
else if (client_crc != expected_crc) reply[1] = 0x01;
else if (esp_ota_end(ota_handle) != ESP_OK) reply[1] = 0xFF;
else {
esp_ota_set_boot_partition(update_partition);
esp_restart(); // (or defer the restart depending on UX)
}
send_notification(conn_handle, ota_control_handle, reply, 2);
return 0;
}
case 0x03: // ABORT
esp_ota_abort(ota_handle);
return 0;
}
return BLE_ATT_ERR_INVALID_PDU;
}
// OTA Data write callback — append to flash
static int ota_data_write_cb(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
uint16_t len = OS_MBUF_PKTLEN(ctxt->om);
esp_ota_write(ota_handle, ctxt->om->om_data, len);
bytes_received += len;
return 0;
}
That’s the entire server-side wire protocol. ESP-IDF’s esp_ota_* family handles the dual-partition swap, signature verification (if enabled), and boot flag setting. You don’t reinvent the OTA partition logic; Espressif already shipped it.
For the MTU upgrade, add this to your firmware init:
ble_att_set_preferred_mtu(247);
Real-world throughput numbers
Measured on production hardware. Image size = 200 KB.
| Stack | iOS | MTU | PHY | Time | Throughput |
|---|---|---|---|---|---|
| ESP32 + NimBLE | 16.x | 23 (no upgrade) | 1M | ~50 s | ~4 KB/s |
| ESP32 + NimBLE | 16.x | 185 | 1M | ~9 s | ~22 KB/s |
| ESP32-S3 + NimBLE | 16.x | 244 | 1M | ~6 s | ~33 KB/s |
| ESP32-S3 + NimBLE | 17.x | 244 | 2M | ~3 s | ~65 KB/s |
| nRF52840 (Nordic DFU) | 17.x | 244 | 2M | ~2 s | ~100 KB/s |
The biggest single throughput boost is the MTU upgrade (4 KB/s → 22 KB/s). The second biggest is the BLE 5 2M PHY (~2x over 1M).
Bluetooth 5 LE Coded PHY (long-range mode) is slower, not faster. Only use it if you need range, not for OTA.
The five recurring bugs
Bug 1: “Throughput is 4 KB/s no matter what I do”
Cause: firmware never accepted the MTU upgrade. iOS is offering 247, firmware is silently negotiating down to 23.
Fix: add ble_att_set_preferred_mtu(247) to ESP-IDF init (or the Bluedroid equivalent). Verify with peripheral.maximumWriteValueLength(for: .withoutResponse) on the iOS side; it should be 244, not 20.
Bug 2: “OTA works at 10 KB, breaks at 50 KB”
Cause: not checking canSendWriteWithoutResponse. iOS’s L2CAP buffer fills up around 10–20 chunks (depending on MTU); subsequent writes are silently dropped.
Fix: see the back-pressure section above.
Bug 3: “Commit always fails with CRC mismatch”
Causes (in order of frequency):
- Different CRC polynomial on client vs firmware. Standardize on IEEE 802.3 / zlib.
- Endianness mismatch: iOS sent big-endian, firmware expects little-endian. Use
littleEndianexplicitly. - Buffer overrun on firmware side: write succeeded but bytes landed in the wrong place. Check
bytes_received == expected_sizebefore computing CRC.
Bug 4: “OTA completes successfully but device never reboots into new firmware”
Cause: esp_ota_end() succeeded but esp_ota_set_boot_partition() was never called. Or boot signature verification failed.
Fix: log the return values of every esp_ota_* call. Most “OTA broken” reports trace back to one of those returning an unchecked error.
Bug 5: “OTA works at home, fails in customer environments”
Causes (in order of frequency):
- Customer’s connection is flaky and chunks drop mid-transfer. Without per-chunk acks (which v1 skips), you can’t tell.
- Customer is on iPhone in low-power mode, which throttles BLE throughput by ~30%. Set OTA UX expectations accordingly.
- Background suspension mid-transfer. Don’t allow OTA in background; require foreground app.
Mitigation for v1: detect failed CRC at commit, ask user to retry. For v2, add per-chunk seq numbers + resume.
Skip all that: the kit version
You can write the ~70-line OTA client above yourself. You’ll also need GATT discovery, characteristic lookup, error mapping, progress reporting, and several rounds of “why doesn’t this work in production?” With Antenna, the iOS side is:
let updater = OTAUpdater(
peripheral: connected,
writeCharacteristic: otaDataChar,
mtu: 244,
writeWithResponse: false // use the fast path
)
let crc = try await updater.transfer(firmwareData) { progress in
print("OTA: \(Int(progress.fraction * 100))% — chunk \(progress.chunkIndex)/\(progress.totalChunks)")
}
// Hand `crc` to the peripheral's verify endpoint via OTA Control characteristic
try await connected.write(commitFrame(crc: crc), to: otaControlChar)
OTAUpdater handles:
- MTU-aware chunking (via
WriteQueue) - Back-pressure (the
canSendWriteWithoutResponsecheck) - Progress reporting per chunk
- Cancellation via Task.cancel
- CRC32 computation (IEEE 802.3)
- Recovery from in-flight failure
- Mock-testable via the
Peripheralprotocol
$99 with a 30-day refund (pricing).
Further reading
- Apple’s
CBPeripheraldocs — covers individual methods but no end-to-end OTA example - ESP-IDF OTA Documentation — best official source for the firmware side
- Nordic Semiconductor’s nRF DFU protocol spec — the most mature BLE OTA protocol in industry; worth reading even if you don’t use Nordic chips
- WWDC 2017 Session 712, “Advances in Core Bluetooth” — the one Apple WWDC talk that touches MTU upgrade behavior
- Antenna source on GitHub — the production implementation discussed above (private; access via kit purchase)
- Antenna companion firmware spec — the test firmware design we ship for the kit’s hardware validation
Published 2026-05-22 by Shailendra Kumar Ram. Maintainer of Antenna. Six years of shipping production CoreBluetooth at three hardware companies, including TTLock, where OTA firmware update was a quarterly recurring need across millions of locks in the field.