Mobile Development

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

    Kotlin
    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 connectedDevice foreground service type must also be declared in the manifest, alongside FOREGROUND_SERVICE_CONNECTED_DEVICE. Without it, Android 14 and later throw at startForeground.
    • START_STICKY gets 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.

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

    Kotlin
    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:

    1. First connection of a session: autoConnect = false for a fast connect.
    2. On STATE_DISCONNECTED with a non-zero status: close the GATT, then reconnect with autoConnect = true.
    3. Never call connectGatt again on a BluetoothGatt you have not close()d. Leaking GATT clients is how you hit the undocumented limit of about 30 and start getting status 133 for 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.

    JavaScript
    // 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_OPTIMIZATIONS will 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 connectedDevice type, started before the connection.
    • Manifest permissions for BLUETOOTH_CONNECT, BLUETOOTH_SCAN and the foreground service type.
    • CONNECTION_PRIORITY_BALANCED outside 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.