Why BLE on Android Is Harder Than iOS
CoreBluetooth on iOS is consistent and predictable. Android BLE is neither. The Android stack sits on top of Bluedroid (now Fluoride), which interacts with vendor-specific chipsets and drivers. Every OEM modifies the Bluetooth HAL. The same API call can produce different behavior on a Samsung Galaxy S24, a Xiaomi 14, and a Pixel 8.
We have shipped BLE integrations for medical device companies and IoT clients across dozens of device models. The official documentation covers about 60% of what you need. This article covers the rest.
Scanning: The 30-Second Limit
Starting with Android 7, the OS silently stops BLE scans that run for more than 30 seconds without a ScanFilter. No callback. No error. The scan just stops finding devices.
The fix: always attach ScanFilter objects. Filter by service UUID, device name, or manufacturer data. If you need an unfiltered scan, stop and restart it on a 25-second cycle. We use a coroutine-based timer that handles this automatically with a brief overlap window to avoid missing advertisements.
Connection Management: Queue Everything
The Android BLE stack is single-threaded for GATT operations. Issue a characteristic read while a write is in progress and one of them silently fails. The documentation barely mentions this.
The solution is a serial operation queue. Every GATT operation enters a FIFO queue. Each waits for its callback before the next one executes. We build this with a Kotlin Channel and a single coroutine consumer.
- Never issue a GATT operation outside the queue.
- Add a timeout to every operation. Callbacks sometimes never arrive.
- After a connection state change, drain all pending operations.
- Include retry logic with exponential backoff.
Queue every single GATT operation. Half the Android BLE bugs we have debugged trace back to concurrent operations on the GATT layer.
MTU Negotiation: Always Request It
The default BLE MTU on Android is 23 bytes, giving you 20 bytes of usable payload. Call requestMtu(512) immediately after connection. The peripheral negotiates down to what it supports. Most modern devices handle 247 or higher, giving you over 10x the throughput.
On some Samsung devices running Android 9 and 10, requesting MTU immediately after connection fails silently. We add a 600ms delay before the request. It is ugly. It works. Always read the actual negotiated value from onMtuChanged and use it to chunk your writes.
The 133 Error (GATT_ERROR)
Status 133 is the most common and least helpful error in Android BLE. It maps to GATT_ERROR, a catch-all that can mean anything. Here is what triggers it most often:
- Stale bonding info from a factory-reset peripheral. Clear it with
BluetoothGatt.refresh()via reflection. - Too many active connections. Four is the safe limit. Some budget devices cap at two.
- Calling
connectGatt()on a device already mid-connection. - Race conditions in the Bluetooth stack. Disconnect, close, wait 1 to 2 seconds, retry.
Our policy: close the GATT, wait with exponential backoff starting at 1 second, reconnect up to 3 times. This resolves the majority of 133 failures in production.
Background BLE: Foreground Services and Doze
Without a foreground service, the OS kills your BLE connection within minutes of backgrounding. This is not optional. On Android 14 and above, declare foregroundServiceType="connectedDevice". On older versions, "location" was the workaround.
Even with a foreground service, deep Doze can defer scans and drop connections when the screen is off and the device is stationary. The only reliable fix is guiding users to disable battery optimization for your app.
OEM Battery Killers
Samsung puts apps in "Sleeping" and "Deep Sleeping" lists that override standard Android. Users must add your app to "Never Sleeping." Xiaomi is worse. MIUI has a battery saver, auto-start manager, and background limiter. All three need configuration. We have watched Xiaomi kill a foreground service 30 seconds after screen-off because auto-start was disabled.
Huawei EMUI defaults to "automatic" app launch management, letting the OS kill your process at will. Users must enable three toggles manually. There is no programmatic fix for any of this. We maintain a mapping of OEM models to battery-settings deep-link intents and surface a guided setup screen on first launch.
Testing: Real Devices Only
The Android emulator does not support Bluetooth. Every BLE feature must be tested on physical hardware. We keep a device lab covering Pixel, Samsung, Xiaomi, OnePlus, and a budget Android Go device. For peripheral simulation, we use Nordic nRF52 dev kits running custom firmware that mimics the target GATT profile.
Our Approach: Abstraction Over Platform BLE
We build a platform abstraction layer that hides the Android BLE stack behind a clean, coroutine-based API. Scanning, connection management, operation queuing, MTU negotiation, retry logic, and OEM detection all live inside this layer. Client code interacts with a BleDevice interface exposing suspend functions. Errors are sealed classes. Connection state is a StateFlow.
When a new OEM quirk surfaces, we fix it in one place. When a new API level changes BLE behavior, the abstraction absorbs it. Application code stays focused on business logic.
If you are building a product that depends on BLE and want a team that has already navigated these problems, let's talk about your project.