Why your BLE writes silently fail on iOS
By Shailendra Kumar Ram · · 7 min read
You call writeValue, no error comes back, and the peripheral behaves as though you never wrote anything. Or worse: it works on your desk with a 40-byte payload and loses half of a 4 KB transfer on a tester's phone. A write that returns no error has been accepted by the stack, which is not the same as acted on by the device. With .withResponse you at least learn that the peripheral's ATT layer acknowledged the write. With .withoutResponse you learn nothing at all. Neither one tells you the firmware did anything useful with the bytes, and the gap between those three things is where these bugs live.
1. Write-without-response is allowed to drop your data
A .withoutResponse write is fire-and-forget by design. It is handed to a transmit buffer with no acknowledgement from the peripheral and no flow control from the ATT layer. When that buffer is full you get no error, no callback and no indication that anything went wrong. Apple's documentation does not promise the overflow will be delivered, which is why the supported approach is to ask before writing rather than to write and hope.
This is why a tight loop is the first thing I check on any BLE integration I am called into with a truncated transfer. It looks correct and it is not:
// Wrong. Everything after the buffer fills is thrown away.
for chunk in chunks {
peripheral.writeValue(chunk, for: characteristic, type: .withoutResponse)
}Since iOS 11 there is a supported way to pace this. Check canSendWriteWithoutResponse before every write, and wait for the delegate to tell you the peripheral is ready again.
private var pending: ArraySlice<Data> = []
private var target: CBCharacteristic?
private func drain(_ peripheral: CBPeripheral) {
guard let target else { return }
while peripheral.canSendWriteWithoutResponse, let chunk = pending.popFirst() {
peripheral.writeValue(chunk, for: target, type: .withoutResponse)
}
}
// Called when the transmit buffer has room again.
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
drain(peripheral)
}2. You are waiting for a callback that only fires for one write type
didWriteValueFor is called for .withResponse writes only. With .withoutResponse it never fires, because there is no response to report. Teams write a chunked transfer against .withoutResponse, wait in didWriteValueFor for the signal to send the next chunk, and watch the transfer stall at chunk one with nothing in the logs.
Pick one model and stay in it. Use .withResponse and sequence on didWriteValueFor when correctness matters more than speed, or use .withoutResponse and sequence on peripheralIsReady when throughput matters. Mixing the two gives you the failure modes of both.
3. Your payload is larger than the negotiated limit
Never hardcode a chunk size. Ask the peripheral, per write type, after connecting:
let maxNoResponse = peripheral.maximumWriteValueLength(for: .withoutResponse)
let maxWithResponse = peripheral.maximumWriteValueLength(for: .withResponse)- For .withoutResponse the limit follows the negotiated ATT MTU, so it varies by device, by iOS version and by what the peripheral agreed to. On recent iPhones it is often 182 bytes, which is a 185-byte ATT MTU minus the 3-byte header, but treat that as an observation from particular hardware rather than a constant.
- For .withResponse iOS will fragment a larger value for you, up to 512 bytes, using the long-write procedure.
- The MTU is negotiated during connection setup, not before. Read the .withoutResponse limit too early and you get the 23-byte default ATT MTU, which leaves 20 usable bytes and a needlessly slow transfer. Reading it in didDiscoverCharacteristicsFor is late enough to be safe. The .withResponse limit does not track the MTU, so it is not affected either way.
4. The characteristic does not support the write type you asked for
A characteristic advertises what it accepts, and the two write properties are separate. Asking for a write type the characteristic does not declare is a reliable way to get silence.
if characteristic.properties.contains(.writeWithoutResponse) {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else if characteristic.properties.contains(.write) {
// Slower, but at least it is a write type the characteristic declares.
peripheral.writeValue(data, for: characteristic, type: .withResponse)
} else {
// Neither property. Writing here goes nowhere, so say so loudly
// rather than failing the way this whole post describes.
assertionFailure("\(characteristic.uuid) is not writable")
}Log characteristic.properties once for every characteristic you intend to use. It takes one line and it settles an argument with the firmware team that would otherwise take an afternoon.
5. The write is fine and the peripheral cannot keep up
If you have ruled out the first four, the constraint is usually on the other side of the radio. A peripheral that writes each chunk to flash needs milliseconds per operation, and flash erase cycles are slower still. iOS will happily deliver faster than the device can commit.
The symptom that identifies this one: the transfer succeeds when you add a delay and fails when you remove it. A delay that fixes a transfer is not a fix, it is a measurement. It tells you the protocol has no back-pressure of its own, and the durable answer is an acknowledgement from the peripheral every N chunks, sized so the device controls the pace instead of your timer.
When the characteristic is the wrong tool
If you are moving hundreds of kilobytes and fighting the write path the whole way, the honest answer may be that GATT characteristics are not built for it. CoreBluetooth exposes L2CAP channels through CBL2CAPChannel, which give you a stream rather than a queue of attribute writes, with flow control handled by the transport instead of by your own chunking code. It needs support on the peripheral side, so it is a firmware conversation rather than an app change, but for bulk transfer it removes most of this post from your life.
How to tell them apart quickly
Count bytes at both ends before you theorise about which layer is wrong.
Log the total bytes handed to writeValue on the app side and the total bytes received in firmware. If the app sent everything and the device received less, you have cause 1 or cause 5. If the app sent less than you intended, you have cause 2 or 3. If nothing arrived at all, check the properties first. That one comparison eliminates most of the search space before you attach a sniffer.
The primary sources for all of this are Apple's CoreBluetooth documentation for canSendWriteWithoutResponse, maximumWriteValueLength(for:) and peripheral(_:didWriteValueFor:error:), plus the Bluetooth Core Specification for the ATT and GATT behaviour underneath. Numbers here shift with iOS releases and with the peripheral stack, so confirm them on your own hardware rather than trusting any published figure, this one included. If you have a transfer that works on the bench and fails in the field, send over the protocol document and a packet log and we can work out which end is dropping data.