Bluetooth LE work has a reputation for taking longer than estimated, and the reason is not that the protocol is hard. It is that iOS and Android disagree about almost every operational detail, and none of those disagreements show up until you have hardware in a room with a real phone.
This is the list we work through. It assumes you already know what a characteristic is; it is about what the two platforms do to you afterwards. The market and business case is a separate post.
A connection is a state machine, not a socket
The most common architectural mistake is treating a BLE connection like a network request: call, await, handle the error. It is closer to a long-lived session with seven distinct stages, each of which fails in its own way.
The practical consequence is in your error handling. “Could not connect” is not a message a user can act on. “Found your device but it would not pair — hold the button for five seconds” is. That distinction requires knowing which stage failed, which requires modelling the stages explicitly rather than wrapping the whole thing in one try block.
MTU: the packet is smaller than you think
The default ATT MTU is 23 bytes. Three of those are protocol header, so you get 20 bytes of payload per packet until something negotiates upward.
The two platforms behave differently, and both behaviours surprise people:
- iOS negotiates for you, shortly after connecting, and you cannot push it higher. In practice it settles around 185 bytes, leaving 182 for data. Apple does not document a fixed value, so treat that as observed behaviour — read the real figure at runtime rather than hard-coding it.
- Android does nothing until asked. You call the MTU request yourself, and the size you get back is the one you have, which may be smaller than the one you asked for.
The trap is timing. Request the MTU after the connection is up but before service discovery and before any reads or writes. Anything sent in the gap goes out at 23 bytes no matter what you negotiate a moment later, and on some devices an immediate request fails silently, so a short delay before asking is a real and widely used workaround rather than superstition.
If iOS is a target, design your application protocol around 182-byte chunks and let Android do better where it can. Designing around the bigger number and discovering iOS will not go there is a protocol change, and protocol changes mean firmware.
GATT caching: the bug that wastes the most time
Both platforms cache the peer’s service table. This is a sensible optimisation — rediscovering services on every connection is slow and expensive — and it is the single largest consumer of developer hours in BLE work.
The symptom is specific and confusing: you change the firmware’s GATT table, flash the device, and the app keeps talking to the old one. Characteristics that you deleted still appear. New ones do not. Nothing in the logs says “stale cache”, because from the phone’s point of view nothing is wrong.
The discipline that avoids it:
- Change the device name or address whenever the GATT table changes during development. The phone then treats it as a device it has never met and rediscovers everything.
- Implement the Service Changed indication in firmware. This is what it is for, and a surprising number of devices ship without it.
- Know how to clear the cache on each platform, and put that in your team’s onboarding notes, because every new engineer loses a day to this exactly once.
If a bug report says “it works on my phone but not on his”, this is the first thing to rule out.
Connection intervals you do not control
The connection interval sets how often the two radios talk. It decides both responsiveness and battery life, and the peripheral can only request it — the central device decides.
- iOS enforces a minimum of around 15–30 ms and will quietly ignore a request below it.
- Android varies by manufacturer, with no published matrix.
So firmware written against the specification’s old 7.5 ms minimum gets a different interval, and therefore a different throughput, on every phone it meets. If your product has a latency requirement — a control surface, a game controller, a medical alarm — measure it on the actual device matrix before you promise it.
Core 6.2 cut the specification minimum to 375 µs, which matters a great deal for the next generation of devices and not at all for the phone in your pocket today. Treat it as a roadmap item, not a solution.
Android permissions, and the Play review behind them
This is where projects get blocked at the worst possible moment, because it is a store-review problem rather than a code problem.
Android Bluetooth permissions, and when you need each one
| Permission | You need it when | Applies |
|---|---|---|
| BLUETOOTH_SCAN | Looking for devices | API 31+ |
| BLUETOOTH_CONNECT | Talking to an already-paired device | API 31+ |
| BLUETOOTH_ADVERTISE | Making this device discoverable | API 31+ |
| BLUETOOTH, BLUETOOTH_ADMIN | Anything, on API 30 and below | maxSdkVersion 30 |
| ACCESS_FINE_LOCATION | Only if scan results derive physical location | Any |
The shortcut, and its cost
Declaring neverForLocation avoids the location permission entirely. Android’s own warning: “If you include neverForLocation in your android:usesPermissionFlags, some BLE beacons are filtered from the scan results.”
The path most products should take
Companion Device Manager, from API 26: “The CDM system provides a pairing UI on behalf of your app and doesn’t require location permissions.”
android:maxSdkVersion="30" on the two legacy permissions so newer devices are not asked for things they no longer use.The decision you have to make deliberately is about location. Historically a Bluetooth scan could be used to infer where someone is, so Android tied scanning to location permission. From API 31 you can declare that you never do that:
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Only on API 30 and below. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
That declaration avoids asking for location, and it avoids the separate Play Store review that background location triggers. It is not free: Android’s own documentation warns that “If you include neverForLocation in your android:usesPermissionFlags, some BLE beacons are filtered from the scan results.” If your product finds devices by beacon advertisement, test this specifically rather than assuming.
For most connected products there is a better answer than either path. Companion Device Manager, available since API 26, presents a system pairing dialog that shows nearby devices and lets the user pick one. In Android’s words, it “The CDM system provides a pairing UI on behalf of your app and doesn’t require location permissions.” You trade some control over the pairing experience for a much simpler permission story and a much easier review. If your app connects to one device that the user owns — which describes nearly every hardware product — that trade is usually right.
Worth noting that this all lands inside the target-API treadmill: the permission model you ship against is the one your target API level selects. Our guide to the Google Play target API requirement covers that cycle.
Background is not a switch you flip
Every connected product eventually wants to do something while the app is not on screen: log a reading, finish an update, notice the device came back in range. Both platforms allow some of that, and both take something away.
What happens when the app is not on screen
iOS
State Preservation and Restoration
What you get
- The system relaunches the app in the background when a preserved task completes.
What you do not
- It does not relaunch after the user force-quits from the app switcher, or toggles Bluetooth off and on in Settings.
Android
Foreground services and filtered scans
What you get
- A foreground service with a visible notification keeps a connection alive.
What you do not
- More than 5 scan starts in 30 seconds is throttled, and the next one is simply refused.
- PendingIntent scanning does nothing without at least one ScanFilter — an unfiltered background scan is not allowed.
The iOS hole is the one that generates support tickets. State Preservation and Restoration genuinely works — the system will relaunch your app in the background when a preserved task completes — but it will not bring you back if the user force-quit the app from the switcher, or toggled Bluetooth off and on in Settings. Both are things ordinary people do, and neither is recoverable in code.
On Android the limits are quantitative and documented. Start scans too often and
you are throttled. Scan from a PendingIntent without a filter and nothing
happens at all, by design.
The honest product consequence: do not promise a feature that depends on the phone noticing something while the app is closed. Design the device to buffer its data and hand it over on the next connection, and the feature stops depending on behaviour you do not control.
Native, cross-platform, or both
The libraries are real and maintained. Versions as of the date on this post:
react-native-ble-plx (React Native) — 3.5.1, published 2026-02-18. Maintained
flutter_blue_plus (Flutter) — 2.3.14, published 2026-10-03. Maintained — the de facto choice
flutter_blue (Flutter) — 0.8.0, published 2021-03-15. Abandoned — predates the Android 12 permission split
The gap between flutter_blue_plus and the original flutter_blue is worth
looking at: one was published days before this post, the other has not been
touched since 2021, which is two years before the Android permission split that
broke every app using it. Check what your project actually depends on.
Our recommendation, which matches what BLE specialists generally advise: a disciplined hybrid. Put the connection state machine, the queueing and the error taxonomy in native Swift and Kotlin, where you can reach every platform API and debug with platform tools. Expose a small, deliberate interface to the cross-platform layer, and build the UI there.
The reason is not that the libraries are bad. It is that BLE problems are platform problems, and an abstraction that hides the platform also hides the bug. When you are three days into a reconnection failure on one Samsung model, you want to be reading Android’s stack, not a wrapper’s.
A test plan that finds the real bugs
Most BLE test plans test the happy path on two phones. The bugs that reach support come from everything else:
- A device matrix of real hardware, weighted to what your customers own. Include at least one Samsung, one Pixel and one budget device from a manufacturer with an aggressive battery manager.
- States you have to force: device powered off mid-operation; phone Bluetooth toggled during a transfer; app force-quit while connected; airplane mode on and off; device at the edge of range; two phones trying to connect at once.
- A firmware version matrix. Customers run old firmware. Your app will meet it for years.
- Logging that distinguishes the halves. Every log line should make it obvious whether the phone or the device went wrong. Without this, support escalates everything to firmware.
If you are standing up this work and would rather not learn each of these the expensive way, connected hardware apps is what we do. Send us the GATT table and we will tell you what the app will cost to build around it.