Why BLE Disconnects When Your Android Screen Turns Off
A practical investigation into Android background restrictions, BLE connection behaviour and how to make connections survive a locked screen.
The bug report is always the same sentence: “it works while I’m looking at it.”
You pair a peripheral, stream notifications, everything is smooth. The user locks their phone, puts it in a pocket, and thirty seconds to five minutes later the connection is gone. On some devices it comes back. On others it stays dead until the app is reopened.
This is almost never a Bluetooth stack bug. It is Android deciding your process is no longer important.
What actually happens when the screen turns off
Screen-off is the trigger for three separate mechanisms, and they are frequently confused with each other.
Doze starts when the device is stationary, unplugged and the screen has been off for a while. Network access is deferred, wakelocks are ignored, alarms are batched into maintenance windows. Doze does not close an existing GATT connection, but it will stop the work your app does in response to notifications.
App Standby Buckets classify your app by how recently the user interacted with it. A rarely-used app in the restricted bucket gets a handful of jobs per day. Nothing about BLE is special here - your callbacks simply never get scheduled.
Background execution limits, introduced in Android 8, are the one that actually kills connections. When your app has no visible activity and no foreground service, the system may stop the process. A stopped process means the BluetoothGatt object is garbage, and the peripheral sees a disconnect.
| Mechanism | Kills the GATT link? | Triggered by |
|---|---|---|
| Doze | No | Screen off + stationary + unplugged |
| App Standby Bucket | No | Low user engagement |
| Background execution limits | Yes | No foreground component |
| Background scan throttling | Scanning only | Screen off, since Android 7 |
The fourth row is worth calling out because it produces a symptom that looks identical from the outside. Since Android 7, a startScan from the background is throttled to roughly one scan result every 30 minutes. If your reconnection strategy is “scan for the device again”, it will appear to hang - but the connection is not what failed. The rediscovery is.
Step one: make the process undismissable
There is no flag that exempts a BLE connection from process death. The only supported answer is a foreground service, which is Android’s way of saying “the user knows this is running.”
class BleConnectionService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Connected to sensor")
.setSmallIcon(R.drawable.ic_bluetooth)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
}Two details matter more than the rest:
- The
connectedDeviceforeground service type must also be declared in the manifest, alongsideFOREGROUND_SERVICE_CONNECTED_DEVICE. Without it, Android 14 and later throw atstartForeground. START_STICKYgets you restarted after a low-memory kill, but your GATT object does not survive. Treat a restart as a cold start and reconnect deliberately.
The notification is not optional and users will see it. Make it say something useful.
Step two: stop asking for a fast connection you don’t need
Connection interval is negotiated, not fixed. A high-priority connection asks for intervals around 11-15 ms, which is excellent for throughput and terrible for a device that has to stay connected for eight hours in a pocket.
gatt.requestConnectionPriority(
BluetoothGatt.CONNECTION_PRIORITY_HIGH
)Request CONNECTION_PRIORITY_HIGH for the burst - a firmware update, an initial sync - and drop back to CONNECTION_PRIORITY_BALANCED as soon as it finishes. Leaving it high is a common cause of the peripheral dropping the link on its own once its battery budget runs out.
A supervision timeout of four seconds with a 15 ms interval means the peripheral gives up after roughly 260 missed events. With a 200 ms interval, it tolerates twenty. The tolerant configuration is the one that survives a phone that is busy doing something else.
Step three: reconnect without scanning
Once you have the device address, you never need to scan again. autoConnect = true hands the reconnection to the Bluetooth stack itself, which keeps a background connection request alive across screen-off and Doze.
val gatt = device.connectGatt(
context,
/* autoConnect = */ true,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)It is slower to connect - the stack uses a long scan window - and that is the trade-off. The pattern that works well in practice:
- First connection of a session:
autoConnect = falsefor a fast connect. - On
STATE_DISCONNECTEDwith a non-zero status: close the GATT, then reconnect withautoConnect = true. - Never call
connectGattagain on aBluetoothGattyou have notclose()d. Leaking GATT clients is how you hit the undocumented limit of about 30 and start getting status133for no visible reason.
If you are on React Native
The JavaScript side of your app is asleep long before the native connection is. react-native-ble-plx keeps the native manager alive, but any reconnection logic that lives in JS will not run when the screen is off.
The workable split is: connection lifecycle in a native foreground service, and JS as a consumer of state that it re-reads on resume. If you are doing reconnection from a useEffect, you have a bug that only appears when nobody is watching.
// Runs on resume, not while the screen is off - treat it as a reconciliation
// step, never as the reconnection mechanism.
AppState.addEventListener('change', (state) => {
if (state === 'active') syncConnectionState();
});Things that look like fixes and are not
- Wakelocks. They keep the CPU on, drain the battery and do nothing about process death.
- Battery optimisation exemptions. Useful as a last resort for enterprise deployments, but
REQUEST_IGNORE_BATTERY_OPTIMIZATIONSwill get a consumer app rejected from Play, and it does not prevent the process kill you are actually hitting. - Retrying faster. If the process is dead, nothing retries. If it is alive, the stack is already retrying better than you are.
A checklist that ends the bug report
- Foreground service with
connectedDevicetype, started before the connection. - Manifest permissions for
BLUETOOTH_CONNECT,BLUETOOTH_SCANand the foreground service type. CONNECTION_PRIORITY_BALANCEDoutside of bursts.- Reconnect via
autoConnect, never via a background scan. gatt.close()on every terminal state, no exceptions.- Test with the screen off, the device stationary and unplugged, for at least thirty minutes. Doze does not engage on a phone sitting on a desk with a cable in it.
The connection was never fragile. The process was.