Skip to content
Connected Hardware

BLE apps: the platform traps that cost you weeks

MTU, GATT caching, connection intervals, Android permissions and background limits: the iOS and Android differences that turn a two-week BLE feature into two months.

Ingenious Techlab Team7 min read
Contents
  1. A connection is a state machine, not a socket
  2. MTU: the packet is smaller than you think
  3. GATT caching: the bug that wastes the most time
  4. Connection intervals you do not control
  5. Android permissions, and the Play review behind them
  6. Background is not a switch you flip
  7. Native, cross-platform, or both
  8. A test plan that finds the real bugs

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 seven stages of a Bluetooth LE connection and where the platforms differScan: Android needs a filter in the background; iOS cannot see raw addresses. Connect: Android queues badly under load; retries are yours to write. Discover: Both cache the service table — the classic stale-GATT bug. Negotiate: iOS sets the MTU for you; Android waits to be asked. Subscribe: Notification writes are a queue, not a call. Operate: Interval differs by phone, so throughput does too. Reconnect: The state most demos skip and every user meetsEvery stage is a place to fail, and a place the platforms disagreeScan
Android needs a filter in the background; iOS cannot see raw addresses
Connect
Android queues badly under load; retries are yours to write
Discover
Both cache the service table — the classic stale-GATT bug
Negotiate
iOS sets the MTU for you; Android waits to be asked
Subscribe
Notification writes are a queue, not a call
Operate
Interval differs by phone, so throughput does too
Reconnect
The state most demos skip and every user meets
a dropped link puts you back at the start
Each stage can fail without the others failing, and a good app tells the user which one did. “Connection failed” is not a message anyone can act on.

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.

Usable payload per Bluetooth LE packet, before and after MTU negotiationBefore negotiation the ATT MTU is 23 bytes, of which 20 carry data. After negotiation iOS typically reaches 185 bytes, of which 182 carry data. Android does not negotiate unless the app requests it.What fits in one packetBefore negotiation20 bytes of dataAfter, on iOS182 bytes of dataShaded tail is the 3-byte ATT header, which you never get back.The window nobody plans forTraffic sent before the negotiation completes goes out at the small size regardless.
Three bytes of ATT header come off every packet. The iOS figure is what iOS is observed to settle on, not a value Apple documents — read the real one at runtime rather than hard-coding 182.

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:

  1. 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.
  2. Implement the Service Changed indication in firmware. This is what it is for, and a surprising number of devices ship without it.
  3. 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

PermissionYou need it whenApplies
BLUETOOTH_SCANLooking for devicesAPI 31+
BLUETOOTH_CONNECTTalking to an already-paired deviceAPI 31+
BLUETOOTH_ADVERTISEMaking this device discoverableAPI 31+
BLUETOOTH, BLUETOOTH_ADMINAnything, on API 30 and belowmaxSdkVersion 30
ACCESS_FINE_LOCATIONOnly if scan results derive physical locationAny

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.”

Runtime permissions arrive at API 31. Set 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.
Neither column is a bug. Both platforms are protecting a battery you do not own, and a product plan that assumes an always-connected device in a pocket is planning against the operating system rather than with it.

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.

Sources

Verified

This post is part of our work onThe app your hardware needs, built by people who have shipped one

Related reading

Let's talk

Tell us what you are building

Send us the scope and we will come back with an honest assessment: what it takes, roughly what it costs, and whether we are the right people for it.

  • A written reply from an engineer
  • No call required
  • No obligation