Building a Reliable Android BLE Operation Queue

Punch Through 97 (1)

The Android BLE API gives you the tools to scan for peripherals, connect, read and write characteristics, and manage the connection lifecycle. But there’s a critical piece it doesn’t provide: any internal mechanism for queuing operations such that they are executed reliably.

Unlike many other parts of the Android SDK, BLE operations aren’t automatically queued or guarded against invalid timing. The system won’t prevent you from issuing a read before a connection is established, or writing to a characteristic while service discovery is still in progress. It won’t even warn you.

This applies to nearly every operation in the BLE lifecycle: connectGatt(), discoverServices(), readCharacteristic(), writeCharacteristic(), requestMtu(), and others all require strict sequencing. Issuing them too quickly or out of order leads to unpredictable behavior and it’s on you to manage that sequencing manually. 

The good news is that the queuing model Android expects is simple: one operation at a time, with each new command issued only after the previous one succeeds or fails. In this article, we’ll walk through exactly how to implement that pattern.


Designing a BLE Operation Queue for Android

When multiple BLE operations are issued back-to-back, it’s common to see only the first one succeed. The rest can silently fail, trigger no callbacks, or get lost entirely. And since most app logic depends on those operations completing in order, that kind of behavior quickly becomes a blocker.

In small apps or tightly scoped flows, you might get away with waiting for something like onCharacteristicWrite() to fire before issuing the next command. But once BLE logic spans screens, user actions, or multiple types of operations, manual callback chaining doesn’t scale. That’s where a basic queuing mechanism becomes essential.

The goal isn’t to build anything overly complex; it’s just about enforcing a single-operation-at-a-time model. All BLE actions, from service discovery to disconnects, need to be serialized so that the next operation starts only after the previous one completes, whether it succeeds or fails. In most cases, implementing a lightweight operation queue is enough to prevent concurrency-related bugs and make behavior across Android versions and devices much more predictable.

To make this queue work, we first need to define what exactly we’re queuing. That means creating a clear, consistent abstraction for the different types of BLE operations your app performs.

Building an Abstraction for BLE Operations

To start, you’ll need an abstraction for what constitutes a BLE operation. We recommend taking advantage of Kotlin-sealed classes and having each type of BLE operation represented as a subclass of a parent-sealed class. For example, you can create a sealed class called BleOperationType that has the following subclasses:

  • Connect
  • Disconnect
  • CharacteristicWrite
  • CharacteristicRead
  • DescriptorWrite
  • DescriptorRead
  • MtuRequest

Each subclass should encapsulate what the operation it’s representing needs to get executed successfully — a Connect would need the BluetoothDevice handle and a Context object, a CharacteristicWrite would need the BluetoothDevice handle, the BluetoothGattCharacteristic we want to write to, the write type to use, and, of course, a ByteArray representing the write payload. 

Since each operation needs to contain a BluetoothDevice handle, we can even make the BleOperationType (which each operation subclasses) contain a BluetoothDevice handle as an abstract property that its subclasses would override.

/** Abstract sealed class representing a type of BLE operation */
sealed class BleOperationType {
    abstract val device: BluetoothDevice
}


data class Connect(override val device: BluetoothDevice, val context: Context) : BleOperationType()
data class CharacteristicRead(
    override val device: BluetoothDevice,
    val characteristicUuid: UUID
) : BleOperationType()

Managing BLE Operations with a Thread-Safe, FIFO Queue

With operations clearly defined, we can now focus on how to manage their execution order. A simple FIFO queue lets us control when operations run and ensures we don’t trigger the next one until the current task is fully complete.

This queue object will likely live in a class (probably a singleton) that all your ViewModels or Activities have access to via proper dependency injection (DI) practices, such as by using Hilt or Dagger. A good candidate can be a ConnectionManager type class that handles all the app’s BLE needs.

The logic behind this FIFO (first-in, first-out) queue is simple: if an operation gets enqueued, and there is no operation that is currently running or pending, this operation gets executed. Otherwise, nothing happens. Once a pending operation completes, it should signal for the queue’s containing class (e.g., ConnectionManager) to check if there are any operations in the queue waiting to get executed. If there is at least one such operation, it gets popped off the queue to be executed.

Since BLE operations may be enqueued from different threads (even from the UI thread when it’s in response to a button click, for instance), we want to ensure our queue is thread-safe. If you have special threading needs, you may opt to use a ReentrantLock to ensure that only one thread ever gets to handle the BLE operation queue. We typically choose to use a ConcurrentLinkedQueue (without a ReentrantLock) that is part of the java.util.concurrent package to hold our operations. Aside from the queue property, we’ll also maintain a separate property to keep track of any pending operations.

private val operationQueue = ConcurrentLinkedQueue<BleOperationType>()
private var pendingOperation: BleOperationType? = null

Ensuring Thread Safety with @Synchronized

To keep our queue safe across threads, we need to guard access to the shared state that tracks operations. BLE operations are often triggered by UI events, which means that enqueue requests can potentially happen from different threads at the same time.

To avoid race conditions, our enqueue and dequeue logic should be thread-safe. Kotlin no longer supports the synchronized {} block, but the @Synchronized annotation gives us the same protection at the function level. It ensures that only one thread can execute the annotated function at a time, which is exactly what we want when mutating shared data like operationQueue or pendingOperation.

This is why functions like enqueueOperation(), doNextOperation(), and signalEndOfOperation() should all be marked with @Synchronized. It’s a simple but crucial step in building a robust, race-free operation flow.

Processing and Executing Queued BLE Operations

With thread safety handled, we can turn to how operations are actually added to the queue and how the next one gets kicked off when ready.

For our operation queue, we’ll implement a synchronized function that adds a new operation to the queue. It should also kick off the added operation if there is no pending operation.

@Synchronized
private fun enqueueOperation(operation: BleOperationType) {
    operationQueue.add(operation)
    if (pendingOperation == null) {
        doNextOperation()
    }
}
 
@Synchronized
private fun doNextOperation() {
    if (pendingOperation != null) {
        Log.e("ConnectionManager", "doNextOperation() called when an operation is pending! Aborting.")
        return
    }
 
    val operation = operationQueue.poll() ?: run {
        Timber.v("Operation queue empty, returning")
        return
    }
    pendingOperation = operation
 
    when (operation) {
    	is Connect -> // operation.device.connectGatt(...)
    	is Disconnect -> // ...
    	is CharacteristicWrite -> // ...
    	is CharacteristicRead -> // ...
    	// ...
    }
}

In the above code snippet, we also provided an example of a doNextOperation() function that essentially pops an operation off the head of our operation queue, caches it as the pendingOperation property, and then executes the operation depending on the type of BleOperationType subclass the operation represents.

Finalizing BLE Tasks and Advancing the Queue

At this point, our queue can hold and process operations in order, but it’s still missing a way to know when to proceed to the next one.

Since enqueueOperation relies on pendingOperation being null as reassurance that it’s safe to execute another operation, our code presents a deadlock: once an operation gets enqueued and executes to completion, there is no way for us to continue executing any queued operations. We’ll fix this by implementing a way for operations to signal their completions. 

@Synchronized
private fun signalEndOfOperation() {
    Log.d("ConnectionManager", "End of $pendingOperation")
    pendingOperation = null
    if (operationQueue.isNotEmpty()) {
        doNextOperation()
    }
}

This function signalEndOfOperation() will need to be called in places where BLE operations may reach their terminal states, both success and failure. Here’s an example for a characteristic write operation:

override fun onCharacteristicWrite(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    status: Int
) {
    // ...
 
    if (pendingOperation is CharacteristicWrite) {
        signalEndOfOperation()
    }
}

Note how we only signal the end of an operation if pendingOperation is of the expected type. This is to prevent (to a certain extent) rogue or unexpected callbacks from accidentally unblocking the queue.

At this point, you’ve got the core structure in place: a thread-safe queue that handles BLE operations one at a time, ensures callbacks unblock the next task, and cleanly separates operation types via sealed classes. That’s the foundation, but for real-world stability, it’s only half the story. Android BLE is notorious for inconsistent behavior, dropped connections, and opaque error codes. To keep your queue (and your app) running smoothly, you’ll need to account for those.


Tips for Queue Reliability

Even with a solid queuing mechanism in place, real-world BLE behavior on Android is unpredictable. Devices disconnect mid-operation, certain Android versions trigger undocumented edge cases, and callbacks don’t always fire when you expect them to. To make your queue more resilient, you’ll need to proactively account for these failure modes. Here are a few tips to improve reliability of your queue:

1. Handle GATT Errors Gracefully

Callbacks like onCharacteristicRead() or onCharacteristicWrite() include a status parameter—but don’t assume a status of GATT_SUCCESS. Status codes like GATT_FAILURE, GATT_WRITE_NOT_PERMITTED, or GATT_INSUFFICIENT_AUTHENTICATION can pop up in valid scenarios and should be treated as failure outcomes. Regardless of whether an operation succeeds or fails, you still need to call signalEndOfOperation() to unblock the queue for subsequent operations.

Treating non-success statuses as terminal states, rather than ignoring them or retrying blindly, helps avoid deadlocks where the queue gets stuck waiting for the current operation to complete.

2. Implement Retry Logic with Care

Some failures, like status 133, can be transient and may resolve on retry. But retries should never happen automatically or unconditionally. If you retry every failure without bounds, you risk spiraling into an infinite loop or overloading the GATT connection.

Instead, log failures with context (operation type, device, timestamp, Android version) and build in conservative retry logic where it makes sense, such as a single reconnect attempt after a disconnect, or a limited number of retries using an exponential backoff strategy for known flaky operations. If an operation fails repeatedly, clear it from the queue and surface the error to the user or app layer.

3. Reset the Queue on Disconnect or Critical Errors

When a device disconnects (cleanly or otherwise), make sure to fully clear the queue and reset pendingOperation. If the app attempts to reconnect, the queue should start from a clean slate. Otherwise, you risk carrying over stale operations from a previous session, which can corrupt the new connection state or trigger invalid callback sequences.

In your onConnectionStateChange() or equivalent disconnect handler, make sure you:

  • Clear operationQueue
  • Set pendingOperation = null
  • Optionally notify any waiting UI or app logic of the disconnect

Wrapping Up

The reliability of your Android BLE workflow starts with operation queuing. Without a structured, thread-safe approach to sequencing commands, the BLE stack quickly becomes unreliable: callbacks never fire, commands get dropped, and status 133s appear unpredictably. A well-designed queue doesn’t just serialize operations; it handles failures, retries, disconnects, and device-specific quirks, creating a stable foundation your entire BLE workflow can depend on.

You can find our full implementation in this commit on our open-source GitHub repo

For a broader look at BLE development on Android (from scanning and connection flows to MTU negotiation and bonding) explore our Ultimate Guide to Android BLE Development. We also publish regular deep dives, tutorials, and BLE-focused articles in our BLE resource collection.

And if you’re building something complex or just want another set of eyes on your implementation we work with teams every day to bring stable, secure, and scalable connected products to life. Reach out if we can help.

Share:

Punch Through
Punch Through
We’re a team of engineers who obsess over making connected things actually work — reliably, securely, and without the handwaving. From BLE to backend, we build the software and systems behind connected medical devices and custom connected products that can’t afford to fail.

Subscribe to stay up-to-date with our latest articles and resources.