Modern BLE: async/await on iOS and Coroutines on Android
Both CoreBluetooth (2011) and Android’s BluetoothGatt (2013) were designed in the age of delegates and callbacks. Every BLE operation — connect, discover, read, write, subscribe — fires its result into a different method, far away from where you started it. The result is the infamous “callback maze”: logic for a single user action smeared across half a dozen delegate methods, all coordinated by shared mutable state.
We already explored one way out in Callback vs Reactive Programming: reactive streams. But reactive frameworks bring a dependency and a learning curve. Today both platforms ship a native answer — Swift Concurrency (async/await) on iOS and Kotlin Coroutines (suspend + Flow) on Android — that turns the callback maze into linear, cancellable, testable code with zero third-party dependencies.
In this article we will build a small, modern BLE layer on both platforms from the ground up, and cover the sharp edges nobody warns you about: double-resume crashes, leaked continuations, timeouts, and cancellation.
Let’s get started!
The Problem: The Callback Maze
Consider the simplest real task: connect to a peripheral, discover a characteristic, and read its value. Here is what it looks like with the native delegate API on iOS:
1 | // The "before" — logic scattered across the delegate |
Four methods, no return values, and the “result” surfaces in a fifth place with no obvious way to hand it back to the caller that started the flow. Android’s BluetoothGattCallback has the exact same shape. The control flow is inverted, error handling is duplicated, and there is no natural place to express “give up after 10 seconds.”
The goal of this article is to make that whole flow read like this instead:
1 | let data = try await ble.connect(peripheral) |
Foundational Knowledge
The bridge from callbacks to modern concurrency rests on two primitives per platform — one for one-shot operations and one for streams.
| Concept | iOS (Swift Concurrency) | Android (Kotlin Coroutines) |
|---|---|---|
| One-shot (connect, read, write) | withCheckedThrowingContinuation |
suspendCancellableCoroutine |
| Ongoing stream (scan, notifications) | AsyncThrowingStream |
callbackFlow |
| Safe shared state | actor |
Mutex / single-threaded dispatcher |
The mental model is identical on both platforms:
1 | A one-shot callback → suspend the caller, resume it once when the delegate fires |
Everything below is an application of those two ideas. Get them right and the rest is mechanical.
iOS: Swift Concurrency
Bridging a One-Shot Operation
withCheckedThrowingContinuation suspends the calling async function and hands you a continuation. You stash it, and when the matching delegate method fires, you resume it — turning a fire-and-forget callback into a value you can await.
1 | actor BLEManager { |
The same pattern wraps readValue(for:) — store a continuation keyed by characteristic, resume it in didUpdateValueFor with the Data.
Swift 6.2 note: the older overload that took an explicit
isolation:parameter —withCheckedThrowingContinuation(isolation:function:_:)— is now deprecated, replaced by an overload whose body isnonisolated(nonsending). The practical upshot: the continuation closure now runs on the caller’s actor executor, so inside anactoryou can touch isolated state directly without an explicit hop. You don’t write the overload by name — keep callingwithCheckedThrowingContinuation { … }and let the compiler pick the current one.
Bridging a Stream
Scanning and characteristic notifications produce many values over time, so a continuation (which resumes exactly once) is the wrong tool. Use AsyncThrowingStream, and — crucially — stop the underlying work in onTermination so cancellation actually cancels the scan:
1 | func scan(for services: [CBUUID]) -> AsyncStream<DiscoveredPeripheral> { |
Consuming it is now a plain for await loop — and breaking out of the loop automatically stops the scan:
1 | for await found in ble.scan(for: [myServiceUUID]) { |
Notifications as an AsyncThrowingStream
Characteristic notifications (didUpdateValueFor after setNotifyValue(true)) map perfectly onto a throwing stream — values flow until an error or disconnect closes it:
1 | func notifications(for characteristic: CBCharacteristic) -> AsyncThrowingStream<Data, Error> { |
The Payoff
The four-method maze collapses into one readable function:
1 | func readSensor() async throws -> Data { |
SwiftUI Integration
With @Observable and the .task modifier, the stream drives your UI directly and cancels automatically when the view disappears:
1 | struct ScanView: View { |
Android: Kotlin Coroutines
The Android BluetoothGattCallback has exactly the same callback-maze shape, and coroutines solve it with the same two primitives.
Bridging a One-Shot Operation
suspendCancellableCoroutine is the direct analog of iOS’s continuation. It suspends the caller and resumes once when the GATT callback fires:
1 | class BleClient(private val context: Context) { |
Reading a characteristic is the same shape — store the continuation, resume it with the bytes in onCharacteristicRead:
1 | suspend fun readValue(char: BluetoothGattCharacteristic): ByteArray = |
Bridging a Stream
callbackFlow is Android’s AsyncStream. It builds a Flow from a callback-based API and — like onTermination on iOS — uses awaitClose to stop the scan when the collector cancels:
1 | fun scan(serviceUuid: ParcelUuid): Flow<ScanResult> = callbackFlow { |
Collecting it mirrors the iOS for await — and leaving the collector stops the scan:
1 | ble.scan(serviceUuid) |
Notifications as a Flow
Characteristic notifications map onto a callbackFlow just like iOS streams:
1 | fun notifications(char: BluetoothGattCharacteristic): Flow<ByteArray> = callbackFlow { |
The Payoff
1 | suspend fun readSensor(device: BluetoothDevice): ByteArray { |
Lifecycle Integration
viewModelScope / lifecycleScope ties the whole BLE flow to the screen — when the user navigates away, the scope is cancelled, the continuations are cancelled, and awaitClose stops the scan. No manual cleanup:
1 | class ScanViewModel(private val ble: BleClient) : ViewModel() { |
Timeouts and Cancellation
This is where modern concurrency earns its keep. A BLE connection that never completes is a classic hang. With callbacks you would juggle a manual timer and a flag; with structured concurrency, a timeout is one line.
iOS — wrap the awaited operation in a task group that races it against a sleep:
1 | func connect(_ peripheral: CBPeripheral, timeout: Duration = .seconds(10)) async throws { |
Android — withTimeout does it for you, and because we used suspendCancellableCoroutine + invokeOnCancellation, the timeout actually closes the GATT connection:
1 | suspend fun connectWithTimeout(device: BluetoothDevice) = |
Cancellation propagates the same way on both platforms: cancel the parent Task/Job, and every suspended BLE call unwinds and tears down its underlying operation. This is the single biggest reason to adopt native concurrency over hand-rolled callbacks.
Common Pitfalls
1. Resuming a Continuation Twice (Crash)
A continuation may be resumed exactly once. But delegate methods can fire more than once, or a connect-failure can race a timeout. Resuming twice is a hard crash on iOS and an IllegalStateException on Android.
1 | // WRONG — if didConnect fires after a timeout already resumed, it crashes |
Always set the stored continuation to nil immediately after resuming. On Android, guard with if (cont.isActive) before resuming.
2. Leaked Continuations (Hang Forever)
The opposite failure: a continuation that is never resumed. If the peripheral disconnects mid-read and you only resume in onCharacteristicRead, the caller hangs forever. Always resume pending continuations on disconnect:
1 | func didDisconnect(_ error: Error?) { |
withCheckedContinuation (vs withUnsafeContinuation) will at least log a warning when a continuation leaks — keep it during development.
3. Forgetting to Stop Work on Termination
Without onTermination (iOS) / awaitClose (Android), breaking out of a scan loop leaves the radio scanning in the background — draining battery and silently affecting other connections. The stream-stopping callback is not optional.
4. Threading and Actor Isolation
CoreBluetooth delegate callbacks arrive on the dispatch queue you passed to CBCentralManager(delegate:queue:) — not on your actor. The continuation body now runs on the caller’s executor (Swift 6.2’s nonisolated(nonsending)), but the delegate method still fires on CoreBluetooth’s queue. So you must hop into the actor before touching stored continuation state from a delegate callback — Task { await self.didConnect() } — or make the delegate a separate non-isolated object that funnels events into the actor. Calling actor-isolated state directly from the delegate queue is a data race.
On Android, BluetoothGattCallback runs on a binder thread. Confine your continuation state to a single dispatcher (e.g. a newSingleThreadContext or a Mutex) so two callbacks never mutate it concurrently.
5. Serializing Operations
Most BLE stacks allow only one outstanding GATT operation at a time (especially Android, which silently drops a second concurrent write). Do not fire readValue and writeValue concurrently. Serialize them — an actor on iOS naturally does this; on Android, route all operations through a single Channel/Mutex queue.
1 | private val opMutex = Mutex() |
When (Not) to Use This
Native concurrency is the right default for most BLE apps, but be honest about the trade-offs:
| Approach | Best for | Watch out for |
|---|---|---|
| async/await + Coroutines | Linear flows, one-shot ops, simple subscriptions, native dependency-free code | Manual continuation bookkeeping; concurrent-op serialization |
| Reactive (Rx) | Complex multi-stream composition, retry/backoff operators, debounce/merge | Extra dependency, steeper learning curve — see Callback vs Reactive |
| Raw callbacks | Tiny apps, a single characteristic read | Scales into the callback maze |
If your app coordinates many simultaneous streams with sophisticated operators (merge three sensor feeds, debounce, retry with exponential backoff), Rx still composes more elegantly. For everything else — which is most apps — native async/await and coroutines give you 90% of the benefit with zero dependencies.
Best Practices Summary
- One-shot → continuation —
withCheckedThrowingContinuation(iOS) /suspendCancellableCoroutine(Android). Alwaysnil/deactivate it right after resuming. - Streams → AsyncStream / callbackFlow — for scanning and notifications, and always stop the work in
onTermination/awaitClose. - Resume on disconnect — fail every pending continuation when the peripheral drops, or callers hang forever.
- Lean on structured timeouts —
withThrowingTaskGroup/withTimeoutinstead of manual timers and flags. - Serialize GATT operations — one outstanding operation at a time; use an
actoror aMutex/Channelqueue. - Respect callback threading — hop into your actor/dispatcher before touching shared continuation state.
- Tie scopes to lifecycle —
.task {}/viewModelScopeso cancellation cleans up the radio automatically.
Summary
CoreBluetooth and BluetoothGatt were built for a callback world, but you no longer have to live there. With a thin bridging layer — continuations for one-shot operations, AsyncStream/callbackFlow for streams — the scattered delegate maze becomes a handful of linear async/suspend functions that read top to bottom, handle errors with try/catch, and cancel cleanly when the user walks away.
The patterns are nearly identical across iOS and Android, which means one mental model covers both platforms. The sharp edges — double-resume crashes, leaked continuations, operation serialization — are real, but they are finite and well understood. Wrap them once in a small BLE layer, and every feature you build on top reads like the business logic it actually is, not the plumbing underneath.
Have a great weekend!





