Bluetooth without the delegate dance
Bluetooth Low Energy code on Apple platforms has always felt harder than it should. The task is usually simple to describe: find a device, connect to it, read some values, react when they change. But the code we write to do that has never looked anything like that description.
I've spent a lot of time in that gap recently, and the result is two open source projects I'm releasing together:
- BLESwift, an async/await-first BLE library for Swift 6.2 that wraps CoreBluetooth in a single actor. No delegates, no callbacks, no compatibility layer.
- BLESwiftCLI, which gives you
ble, a macOS command-line tool for scanning, connecting, inspecting, and pairing with peripherals, built entirely on BLESwift.
To explain why I built them, let's write the same feature three times: subscribe to heart-rate notifications from a standard BLE heart-rate monitor (service 180D, characteristic 2A37). First the way we all learned it, then the way most production apps do it today, and then the way I think we should be writing it in 2026.
Act one: the delegate era
Here's the raw CoreBluetooth version. Two delegate protocols, and a state machine you maintain by hand across half a dozen callbacks:
final class HeartRateManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
private var central: CBCentralManager!
private var peripheral: CBPeripheral?
private let serviceUUID = CBUUID(string: "180D")
private let measurementUUID = CBUUID(string: "2A37")
func start() {
central = CBCentralManager(delegate: self, queue: nil)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn else { return }
central.scanForPeripherals(withServices: [serviceUUID])
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
central.stopScan()
// Hold a strong reference, or the peripheral deallocates mid-connect.
self.peripheral = peripheral
peripheral.delegate = self
central.connect(peripheral)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.discoverServices([serviceUUID])
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let service = peripheral.services?.first(where: { $0.uuid == serviceUUID }) else {
return
}
peripheral.discoverCharacteristics([measurementUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard let characteristic = service.characteristics?.first(where: {
$0.uuid == measurementUUID
}) else { return }
peripheral.setNotifyValue(true, for: characteristic)
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard let data = characteristic.value else { return }
// Parse the flags byte, then an 8-bit or 16-bit value...
}
func centralManager(_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?) {
// Now what? Reconnect? Rescan? Restart the whole dance?
}
}
That's roughly sixty lines to get one number out of a sensor, and it's not even complete. Every one of those callbacks can fire with an error we're not handling. The flow of the program is scattered across seven methods that CoreBluetooth calls in an order the compiler knows nothing about. And notice the questions piling up in the comments: who owns the peripheral reference, which queue are these callbacks arriving on, and what exactly is our recovery story when the connection drops?
None of this is CoreBluetooth's fault, exactly. It's a faithful projection of the Bluetooth spec onto the design patterns Apple had available in 2011. But we've been paying the readability tax ever since.
Act two: the wrapper era
The community response was a generation of wrapper libraries, and the one I always reach for as the reference point is Bluejay from Steamclock. Bluejay fixed real problems: it queued operations so they ran one at a time in a predictable order, it replaced delegate conformances with closures, it gave us Result types instead of optional error parameters, and its Receivable/Sendable protocols made characteristic decoding a type-level concern instead of ad-hoc Data slicing.
Here's our heart-rate task in Bluejay:
let bluejay = Bluejay()
bluejay.start()
let heartRateService = ServiceIdentifier(uuid: "180D")
let measurement = CharacteristicIdentifier(uuid: "2A37", service: heartRateService)
bluejay.scan(
serviceIdentifiers: [heartRateService],
discovery: { discovery, _ in
.connect(discovery, timeout: .seconds(15), warningOptions: nil) { result in
switch result {
case .success:
bluejay.listen(to: measurement) { (result: ReadResult<HeartRateMeasurement>) in
switch result {
case .success(let reading):
print("\(reading.beatsPerMinute) bpm")
case .failure(let error):
print("Listen failed: \(error)")
}
}
case .failure(let error):
print("Connection failed: \(error)")
}
}
},
stopped: { _, error in
if let error {
print("Scan stopped with error: \(error)")
}
}
)
This is a genuine improvement. It's a third of the code, errors are impossible to ignore, and the operation queue quietly eliminates a whole class of "you called that too early" bugs.
But look at the shape of it. The logic still reads inside-out: the thing we actually care about, the heart-rate reading, lives three closures deep. Callbacks don't compose, so every new requirement nests another level. Bluejay is also explicitly designed around a single connected peripheral, which was the right simplification for its era but rules out a growing category of apps. And the deeper issue is simply time: Bluejay's last release was in 2021, before async/await, before actors, and before Swift 6 strict concurrency redefined what "thread-safe" even means. A Sendable-checked Swift 6.2 codebase and a callback library from 2021 speak different languages.
The language moved underneath the wrapper era. That's not a criticism of those libraries; it's the reason the next one has to exist.
Act three: BLESwift
Here's the same feature in BLESwift:
import BLESwift
let central = Central()
// Wait for the radio to power on.
for await state in await central.stateEvents() {
if state == .poweredOn { break }
}
// Scan until a heart-rate monitor turns up. Breaking out of the loop ends the scan.
let heartRateService = ServiceIdentifier(uuid: "180D")
var found: PeripheralIdentifier?
for try await event in await central.scan(services: [heartRateService]) {
if case .discovered(let discovery) = event {
found = discovery.peripheral
break
}
}
guard let identifier = found else { return }
// Connect, with automatic reconnection on unexpected drops.
let peripheral = try await central.connect(identifier, reconnect: .always(maxAttempts: 5))
// Subscribe to heart-rate notifications.
let measurement = CharacteristicIdentifier(uuid: "2A37", service: heartRateService)
let readings: AsyncThrowingStream<HeartRateMeasurement, Error> =
peripheral.notifications(for: measurement)
for try await reading in readings {
print("\(reading.beatsPerMinute) bpm")
}
The program now reads the way we described the task at the top of this post: wait for the radio, find a device, connect, stream readings. Top to bottom, no nesting, and every failure point is an ordinary try that propagates like any other Swift error. If you've used Bluejay, the ServiceIdentifier/CharacteristicIdentifier vocabulary will look familiar; that's deliberate. BLESwift borrows the parts of that API design that aged well and rebuilds everything underneath them.
The part I care most about
Every CoreBluetooth wrapper has to answer one question: CoreBluetooth delivers its delegate callbacks on a dispatch queue, so how do you get those events into your world safely?
Most wrappers answer with locks, or by hopping to the main queue and hoping. BLESwift's answer is what made me want to build the library in the first place: Central is an actor whose isolation is tied directly to the DispatchSerialQueue that its CBCentralManager delivers callbacks on. Swift 6.2's custom executor support makes the two worlds literally the same serial execution context. Every CoreBluetooth event is already on the actor's executor when it arrives. There's no thread hop, no lock, no reordering hazard, and no "this closure might run on a stale state" class of bug, because the compiler enforces the isolation rather than a code comment asking you to be careful.
That one decision cascades into everything else the library does:
- Multi-peripheral connections. Connect to as many peripherals as you like; each gets its own connection lifecycle, reconnect policy, and GATT state.
- Declarative reconnection.
ReconnectPolicy(.never,.always(maxAttempts:backoff:), or fully custom backoff) replaces the manual retry bookkeeping from thatdidDisconnectPeripheralcomment in act one. - Multicast everything. Bluetooth state, connection lifecycle events, and characteristic notifications are all real multicast streams. Any number of subscribers can iterate them concurrently, and each observes independently.
- Background restoration. iOS state restoration surfaces as a single buffered, replay-on-subscribe event stream instead of a delegate method that fires before you're ready for it.
Testing without hardware
My favorite consequence of the design is the testing story. BLESwift ships a BLESwiftTestSupport product with FakeCentral and FakePeripheral, scriptable stand-ins for CBCentralManager and CBPeripheral, and Central has a public initializer that wires a real Central to them instead of the radio:
import BLESwift
import BLESwiftTestSupport
let queue = DispatchSerialQueue(label: "MyAppTests.rig")
let fakeCentral = FakeCentral(queue: queue)
let central = Central(backend: fakeCentral, queue: queue)
fakeCentral.simulateStateChange(.poweredOn)
// Script discoveries, connections, reads, writes, and notifications from here.
Everything above the backend is the production code path, so your scan/connect/subscribe logic runs for real in unit tests, with no heart-rate strap velcroed to your CI machine.
The CLI: proof it works
Libraries built in a vacuum grow APIs that only work in a vacuum. So while building BLESwift, I dogfooded it into a real tool: ble, a macOS command-line app for poking at BLE peripherals.
brew install kylebrowning/tap/ble
ble scan gives you a live table of everything advertising nearby, sorted by signal strength and updating in place. ble connect resolves a device by UUID or name substring and holds the link open. ble pair triggers the macOS pairing dialog by touching a protected characteristic, which is genuinely the only way to do it, since CoreBluetooth has no pairing API. And ble inspect dumps a device's entire GATT tree with standard Bluetooth SIG attributes labeled by name:
$ ble inspect mydevice --read
Service 180F — Battery
2A19 — Battery Level [read, notify] = 0x5A (1 byte, uint 90, "Z")
Descriptor 2902 — Client Characteristic Configuration
There's also ble read (with --notify streaming), ble write with structured YAML/JSON payload files, and ble l2cap for connection-oriented channels. Everything prints data to stdout and status to stderr, so ble scan --json | jq works the way you'd hope.
Beyond being a tool I now use constantly, the CLI is the existence proof for the library. Multi-peripheral scanning, connection lifecycles, reconnect policies, GATT enumeration, and the fake-backed tests all get exercised by a shipping app. Even the CLI's own test suite runs against FakeCentral, no radio required.
Try it
If you're writing BLE code in Swift, I'd love for you to kick the tires:
- Add the library:
.package(url: "https://github.com/kylebrowning/BLESwift.git", from: "1.0.0"). It supports iOS 18, macOS 15, watchOS 11, tvOS 18, and visionOS 2, and its only dependency is swift-log. - Install the tool:
brew install kylebrowning/tap/ble, then runble scanand see what's chattering around you. It's a great way to learn how BLE actually behaves before writing a line of app code. - Read the DocC catalog in the BLESwift repo. It walks through scanning, connections and reconnection, reads/writes and notifications, and the discipline background restoration requires.
Both projects are open source under Apache 2.0, and issues and pull requests are very welcome, especially reports from weird peripherals. BLE hardware is an endless source of those, which is exactly why our tools for talking to it should be boring, safe, and readable.