TEJVON
Back to Engineering Impact
BLE Architecture 10 min readJune 2026

The BLE Command Queue: Eliminating Race Conditions in Concurrent GATT Operations

Android BLE allows exactly one GATT operation at a time. Everything else is a race condition. Here is the command queue that fixes it.

Summary

Android's BLE stack allows only one GATT operation at a time. Calling read, write, or setNotification concurrently produces unpredictable failures. A command queue with priority, timeout, and retry policies is the correct architectural solution. This is the exact implementation used in a production connected device system.

T

Surendra — TEJVON

Senior Android & BLE Engineer

Every Android BLE developer eventually hits the same wall: their app calls gatt.writeCharacteristic() and gatt.readCharacteristic() in close succession, and one of them silently fails. No error. No callback. The operation just disappears. This is not a bug — it is a documented constraint of Android's GATT stack: exactly one operation may be in flight at any time.

The naive fix is to chain operations in callbacks. The production fix is a command queue. This article presents the queue architecture I used in a connected automotive device system where the Android app communicated simultaneously with 3 GATT services and 11 characteristics, with operations arriving from UI events, background sync, and periodic telemetry requests.

Warning

Calling multiple GATT operations without queuing is not just unreliable — it produces different failure modes on different OEM devices. On Pixel devices it typically returns false from the operation call. On Samsung it may produce status 133 in the callback. On Xiaomi it often silently drops the second operation with no indication of failure.

Command Queue Architecture

The queue has three responsibilities: serialise all GATT operations so only one runs at a time; provide a callback mechanism for callers to receive results asynchronously; and enforce timeouts so a failed operation (no callback received) does not deadlock the queue forever.

BleCommand sealed class — represents every possible GATT operationkotlin
sealed class BleCommand {
    abstract val timeoutMs: Long
    abstract val priority: Int // lower = higher priority

    data class WriteCharacteristic(
        val characteristic: BluetoothGattCharacteristic,
        val value: ByteArray,
        val writeType: Int = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
        override val timeoutMs: Long = 5_000L,
        override val priority: Int = 5
    ) : BleCommand()

    data class ReadCharacteristic(
        val characteristic: BluetoothGattCharacteristic,
        override val timeoutMs: Long = 5_000L,
        override val priority: Int = 5
    ) : BleCommand()

    data class SetNotification(
        val characteristic: BluetoothGattCharacteristic,
        val enable: Boolean,
        override val timeoutMs: Long = 5_000L,
        override val priority: Int = 3 // higher priority — setup operations
    ) : BleCommand()

    data class RequestMtu(
        val mtu: Int,
        override val timeoutMs: Long = 10_000L,
        override val priority: Int = 1 // highest — must complete before data ops
    ) : BleCommand()
}
BleCommandQueue — coroutine-based serialized GATT operation processorkotlin
class BleCommandQueue(
    private val scope: CoroutineScope,
    private val gatt: BluetoothGatt
) {
    // Priority queue — lower priority value = higher priority
    private val queue = PriorityBlockingQueue<QueuedCommand>(
        16, compareBy { it.command.priority }
    )
    private val isExecuting = AtomicBoolean(false)
    private val pendingContinuation = AtomicReference<Continuation<BleResult>?>(null)

    data class QueuedCommand(
        val command: BleCommand,
        val continuation: Continuation<BleResult>
    )

    suspend fun enqueue(command: BleCommand): BleResult {
        return suspendCancellableCoroutine { continuation ->
            queue.offer(QueuedCommand(command, continuation))
            processNext()
        }
    }

    private fun processNext() {
        if (isExecuting.compareAndSet(false, true)) {
            val next = queue.poll()
            if (next == null) {
                isExecuting.set(false)
                return
            }
            pendingContinuation.set(next.continuation)
            executeCommand(next.command, next.continuation)
        }
    }

    private fun executeCommand(command: BleCommand, continuation: Continuation<BleResult>) {
        scope.launch(Dispatchers.Main) {
            // Timeout watchdog
            val timeout = launch {
                delay(command.timeoutMs)
                val cont = pendingContinuation.getAndSet(null)
                cont?.resume(BleResult.Timeout(command))
                isExecuting.set(false)
                processNext()
            }

            val success = when (command) {
                is BleCommand.WriteCharacteristic -> {
                    command.characteristic.value = command.value
                    command.characteristic.writeType = command.writeType
                    gatt.writeCharacteristic(command.characteristic)
                }
                is BleCommand.ReadCharacteristic ->
                    gatt.readCharacteristic(command.characteristic)
                is BleCommand.SetNotification ->
                    gatt.setCharacteristicNotification(command.characteristic, command.enable)
                is BleCommand.RequestMtu ->
                    gatt.requestMtu(command.mtu)
            }

            if (!success) {
                timeout.cancel()
                pendingContinuation.getAndSet(null)
                    ?.resume(BleResult.OperationFailed(command))
                isExecuting.set(false)
                processNext()
            }
            // On success, the GATT callback will call onCommandComplete()
        }
    }

    // Called from BluetoothGattCallback — signals command completion
    fun onCommandComplete(result: BleResult) {
        pendingContinuation.getAndSet(null)?.resume(result)
        isExecuting.set(false)
        processNext()
    }
}

Usage: Clean, Sequential, Readable

Calling enqueue() — looks synchronous, is non-blockingkotlin
// In your BLE service or manager — setup sequence
suspend fun initializeDevice(gatt: BluetoothGatt) {
    val queue = BleCommandQueue(scope, gatt)

    // These execute sequentially despite being called in sequence
    // Each suspends until the GATT callback fires or timeout occurs
    val mtuResult = queue.enqueue(BleCommand.RequestMtu(247))
    if (mtuResult is BleResult.Timeout) {
        // MTU negotiation timed out — proceed with default 23
    }

    val notifyResult = queue.enqueue(
        BleCommand.SetNotification(telemetryChar, enable = true)
    )

    val deviceInfoResult = queue.enqueue(
        BleCommand.ReadCharacteristic(deviceInfoChar)
    )

    // All done — device is ready for operation
}

// Concurrent callers — all safely queued, none blocked
fun onUserRequestedSync() {
    scope.launch { queue.enqueue(BleCommand.WriteCharacteristic(syncChar, SYNC_CMD)) }
}

fun onTelemetryTimerFired() {
    scope.launch { queue.enqueue(BleCommand.ReadCharacteristic(telemetryChar)) }
}

Production Result

The command queue eliminated all race-condition-related GATT failures in production. 11 characteristics, 3 services, operations arriving from 4 concurrent sources — zero silent drops, zero status 133 from operation overlap. Timeout events occurred occasionally in RF-poor environments and were handled gracefully by the retry layer above the queue.

Topics

BLEArchitectureCommand QueueKotlinCoroutinesGATTConcurrency

Working on a similar challenge?

Let's discuss the architecture before you build.

BLE system design, OTA reliability, and connected product engineering — this is what TEJVON does every day.

Book a Technical Consultation