Engineering

Android BLE in production: scanning, GATT, and background work

Back to Blog

Why Android BLE needs explicit state management

Android exposes a common BLE API across devices, but the application still has to deal with process lifetime, permissions, peripheral firmware, radio conditions, and vendor-specific behavior. A connection that works on a Pixel beside the peripheral is not evidence that it will recover on a Samsung after the app spends an hour in the background.

The useful response is not a larger collection of delays and retries. It is a connection model with explicit states, bounded operations, observable failures, and tests on the devices and peripherals the product will actually support.

Scanning: make it bounded and purpose-specific

Android's BLE scanning guidance recommends stopping as soon as the target is found, never scanning in a loop, and setting a time limit because scanning is battery-intensive. Use a ScanFilter when the protocol gives you a stable service UUID or manufacturer-data signature. Device names are a weaker identifier because they may be absent, duplicated, or changed by firmware.

If discovery must wake a process that is not running, investigate a filtered PendingIntent scan or CompanionDeviceManager. A timer that repeatedly starts scans is not a substitute for choosing the background API that matches the product.

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.

Queue every GATT operation. Concurrent operations create failures that are difficult to reproduce and even harder to diagnose from field logs.

MTU: negotiate, observe, then size packets

The default ATT MTU is 23 bytes, leaving 20 bytes for a notification or write payload after the ATT header. Requesting a larger MTU does not guarantee that value. The application must use the result delivered to onMtuChanged and keep the peripheral's own limits in mind.

There is also a platform-version detail worth encoding in tests: the current BluetoothGatt.requestMtu documentation states that Android 14 requests an ATT MTU of 517 when the first GATT client asks, then disregards later MTU requests for that ACL connection. Do not build packet sizing around the integer passed into requestMtu.

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:

Treat status 133 as a failed operation, not a diagnosis. Capture the preceding state, close the stale BluetoothGatt, and retry only when the product can do so safely. Bound the retries and expose the terminal state to the user. Without the preceding events, radio state, device model, and peripheral logs, “GATT 133” does not tell you which layer failed.

Background BLE: choose the API from the user-visible task

A continuous, user-visible data session may justify a foreground service with the connectedDevice type and its required permissions. Discovery, reconnect, and short data transfers have different options. Android's current background BLE guidance covers filtered PendingIntent scans, WorkManager, auto-connect, and companion-device APIs.

Do not assume that a boot receiver can always restart a foreground service or that requesting exemption from battery optimization fixes every vendor policy. Model what must happen after process death, test the relevant Android versions, and explain any user action in the context of a feature the user has explicitly started.

OEM battery killers

Some phone vendors add battery controls beyond the Android platform model. Test the shipping OS builds, but do not force every user through vendor-specific settings before a failure is observed. When intervention is necessary, explain the affected feature, open only supported settings surfaces, and verify the result.

A private catalogue of undocumented settings intents is brittle. Keep the normal lifecycle correct first: reconnect from explicit user intent, persist resumable state, use the appropriate foreground-service type, and make process death recoverable.

Testing: real devices only

Use physical hardware for end-to-end BLE behavior and maintain a matrix based on customer devices and Android versions. A programmable development kit can reproduce protocol states, but final acceptance still needs the production radio, firmware, antenna, enclosure, and body placement.

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 debugging a BLE product, contact DEVSFLOW with the peripheral, Android versions, and failure trace. Those details are more useful than a general description of an unreliable connection.

Building a neurotechnology product that relies on BLE? We specialize in mobile apps for EEG headsets, biometric wearables, and multi-sensor streaming devices. See our neurotechnology work

DEVSFLOW Technologies

DEVSFLOW Engineering

The mobile engineering team at DEVSFLOW Technologies