BLE MTU on Android vs iOS in React Native, how to pick a safe payload size, chunk writes, and avoid silent failures

BLE MTU on Android vs iOS in React Native, how to pick a safe payload size, chunk writes, and avoid silent failures

Dec 12, 2025
6 minute read

If BLE writes sometimes “work” and sometimes vanish, it’s usually not your JSON, it’s your packet size and flow control. BLE can feel like passing notes through a mail slot, the slot width changes per phone, and some phones won’t tell you the width.

This guide focuses on BLE MTU Android iOS behavior in React Native, how to pick a payload size that won’t bite you later, and how to build chunked transfers that fail loudly (and recover) instead of failing silently.

BLE MTU basics you can actually use in a React Native app

MTU (Maximum Transfer Unit) is the max size of an ATT packet carried over GATT. The common default is 23 bytes MTU, which leaves 20 bytes for your data.

The core formula you should build around is:

usablePayload = negotiatedMTU - 3 (the 3 bytes are ATT protocol overhead)

This “usable payload” is what you can fit into a single notification, indication, or characteristic write at the ATT layer. The Bluetooth spec details the ATT framing and why that overhead exists in the Bluetooth Core spec ATT section.

Where developers get burned is assuming MTU is the same across platforms:

  • Android (central): you can request a larger MTU after connecting. The actual MTU is negotiated with the peripheral and may be lower than what you requested. The native API is BluetoothGatt.requestMtu, documented here: BluetoothGatt.requestMtu reference. In React Native, libraries wrap this in different ways. For react-native-ble-plx, the library’s own notes are in the MTU negotiation wiki.
  • iOS (central): CoreBluetooth negotiates automatically. You don’t reliably “set MTU” from the app side. Many React Native stacks also don’t expose the negotiated MTU on iOS, so you can’t treat MTU as a known number, you treat it as a constraint you learn by successful writes.

One more practical point: MTU is necessary, but not sufficient. Even with a big negotiated MTU, your peripheral can still limit accepted write sizes based on characteristic permissions, buffer space, and whether it supports long writes (Prepare Write).

Picking a safe payload size (Write With Response vs Without Response)

Start by separating “max possible” from “safe default.” A popular target is MTU 247, which gives 244 bytes payload (247 - 3). It’s great when it works, but it’s not a guarantee across every phone, OS version, and peripheral.

A simple cross-platform table helps set expectations:

| MTU (negotiated) | usablePayload (MTU-3) | What it feels like in apps | | ---------------- | --------------------- | --------------------------------------------- | | 23 | 20 | Always works, painfully small | | 185 | 182 | Solid wide-compat sweet spot | | 247 | 244 | Often best speed and stability | | 512 | 509 | Fast on some pairs, not dependable everywhere |

Write With Response vs Write Without Response

Write With Response is the “registered mail” option. Each write gets an ATT response, which acts as built-in backpressure. It’s slower, but it tends to surface errors sooner.

Write Without Response is “drop it in the chute.” It can be much faster, but you must add your own flow control because the OS may queue writes, the peripheral may drop them, and your Promise resolving doesn’t mean the peripheral processed the bytes.

In practice, a good default strategy is:

  • Use Write With Response for control messages (provisioning commands, settings, “start transfer,” “commit,” etc.).
  • Use Write Without Response for bulk data (logs, images, firmware chunks), but only with application-level ACKs and a send queue.

A recommended safe default payload size

If you need one number that works broadly across Android and iOS stacks, pick 180 bytes per chunk. It fits cleanly under the common 185 MTU case (182 payload) while giving you a bit of slack for framing overhead.

Then probe and adjust:

  • Android: after connect, request MTU once (common choices: 247, fallback to 185). Use usablePayload = negotiatedMTU - 3, then subtract your framing bytes.
  • iOS: assume nothing. Use the same 180-byte chunk size until you’ve proven you can go higher. Also be aware that libraries may not expose iOS MTU; the react-native-ble-plx limitation is discussed in this iOS MTU issue. For CoreBluetooth behavior details, the Punch Through CoreBluetooth guide is a solid reference.

Chunk writes that don’t fail silently: framing, reassembly, and flow control

Chunking is not just “split a Buffer and loop.” You need a tiny protocol so you can detect missing pieces and recover.

A robust framing format for each chunk is:

  • len (2 bytes, payload length)
  • seq (2 bytes, chunk sequence number)
  • flags (1 byte, optional, for start/end markers)
  • data (0..N bytes)
  • crc16 (2 bytes, optional, helps catch corruption and desync)

If you use a 180-byte safe chunk, and your header is 5 bytes (len + seq + flags), your data portion is 175 bytes (minus CRC if you add it).

React Native oriented pseudocode (library-agnostic)

Send side (central to peripheral), with a strict queue and app-level ACK:

  1. Negotiate or assume a payload limit: maxData = platform === 'android' ? (mtu - 3 - headerBytes) : 175
  2. Frame and enqueue chunks: chunks = frame(data, maxData)
  3. Write sequentially with a timeout per chunk, keep at most 1 in flight for reliability.

Example flow in TypeScript-style pseudocode (shown inline to keep it readable):

  • for (const chunk of chunks) {
  • await writeWithoutResponse(chunk.bytes)
  • const ok = await waitForAck(chunk.seq, 1200)
  • if (!ok) { await retryChunk(chunk, 2) }
  • }

On the receive side (peripheral or phone), reassembly should reject partial transfers:

  • Track expectedSeq, buffer chunks until complete.
  • If seq jumps, request resend (or abort and restart).
  • If crc fails, NACK that seq.

How to avoid silent failures in real apps

Silent failures usually come from believing the OS queue equals success. Defensive moves that pay off:

  • Treat write Promises as “queued,” not “delivered.” Your app-level ACK is the real delivery signal.
  • Add a transfer session ID so a reconnect doesn’t merge two transfers.
  • Cap concurrency: for Write Without Response, keep one in flight unless you’ve measured your peripheral’s buffer behavior.
  • Watch link quality: low RSSI or a bad connection interval increases drops. If throughput tanks, reduce chunk size before you increase retries.
  • Detect partial transfers: end-of-transfer should include total bytes and a final checksum, then wait for a “commit OK” response written back from the peripheral.

Peripheral-side basics (without going deep into firmware): make sure the characteristic supports the write type you use (Write vs Write Without Response), and remember that “long writes” via Prepare Write behave differently than app-level chunking. If you don’t fully control long write handling, chunk at the application layer and keep it explicit.

Conclusion

BLE transfers are reliable when you treat them like networking, not like a local function call. Use usablePayload = negotiatedMTU - 3, start with a safe 180-byte data chunk, then scale up only after you’ve measured success on real devices. Combine Write With Response for commands, Write Without Response for bulk data, and add application-level ACKs so failures can’t hide. The result is boring in the best way: transfers that either finish, or fail loudly with a reason.