Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

Core Bluetooth on macOS: Central State, Discovery, and Peripheral Recovery

Design Core Bluetooth central workflows around authorization, manager state, bounded scanning, GATT discovery, disconnection, and honest recovery.

Core Bluetooth lets a Mac app scan for nearby Bluetooth Low Energy peripherals, connect, discover services and characteristics, and exchange characteristic data. It is an asynchronous state machine layered over a shared radio environment, not a direct socket to a device. A scan result is an advertisement observed at one point in time; it does not prove that the peripheral is reachable now, that a connection will succeed, or that a particular GATT service is available.

The central role should have one explicit owner. That owner retains the CBCentralManager, tracks discovered CBPeripheral objects, implements both central and peripheral delegates, and publishes value snapshots to the UI. Keep product state such as pairing, user intent, and device identity separate from Core Bluetooth objects so a transient disconnect does not erase the app’s domain model.

Wait for manager state before starting work

Create the central manager with a delegate and a deliberate callback queue. Its state initially may be unknown. Wait for centralManagerDidUpdateState(_:) and start scanning only when the manager reports powered on. Handle powered off, resetting, unauthorized, and unsupported as distinct conditions. Retrying the same scan call while Bluetooth is unavailable is not recovery; show a state-specific explanation and wait for the next manager update.

For a macOS app, include a localized NSBluetoothAlwaysUsageDescription in the app’s information property list. Explain the user-facing feature that needs nearby Bluetooth access; a generic “Bluetooth required” string gives the user little basis for a decision. Apple lists Bluetooth as a protected resource on macOS and identifies this property-list key as its purpose string. Check CBManager.authorization separately from the central manager’s radio state: authorization and whether the Bluetooth radio is powered on are different conditions. A user may deny access while the radio is available. Explain the feature before requesting access, avoid repeatedly prompting, and provide a useful non-Bluetooth path when possible. Do not infer authorization from a discovery timeout or treat it as a transport error.

Scan narrowly and stop promptly

When possible, scan for the service UUIDs the product actually uses. A broad scan produces more irrelevant callbacks and makes discovery behavior harder to reason about. Use advertisement data to decide whether a result is a candidate, not as a trusted identity or a complete device record. A peripheral may omit a service from an advertisement and reveal it only after connection. Core Bluetooth returns only peripherals advertising the requested UUIDs when a service filter is supplied; if the target firmware omits that UUID, use a bounded unfiltered scan or another documented advertisement discriminator, then discover and validate the service after connecting. Apple recommends service filters when the device advertises them.

import CoreBluetooth
import Foundation

final class HeartRateCentral: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    private var manager: CBCentralManager!
    private var candidates: [UUID: CBPeripheral] = [:]
    private var isScanning = false

    override init() {
        super.init()
        manager = CBCentralManager(delegate: self, queue: .main)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        guard central.state == .poweredOn else {
            central.stopScan()
            isScanning = false
            return
        }
        guard !isScanning else { return }
        central.scanForPeripherals(withServices: [CBUUID(string: "180D")], options: nil)
        isScanning = true
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any], rssi: NSNumber) {
        candidates[peripheral.identifier] = peripheral
    }
}

The example is intentionally a discovery skeleton, not a continuously running scan policy. Stop scanning once the user has selected a candidate or a discovery deadline expires. If the feature is idle, stop the scan rather than leaving radio work active. A reconnect loop should use bounded backoff and stop when the user cancels, the peripheral is forgotten, or manager state no longer permits work.

Use the delegate’s callback queue consistently. If it is not the main queue, protect mutable state and send immutable snapshots to the UI. Do not call AppKit from a Core Bluetooth callback unless you are on the main thread. Delegate order and device behavior are asynchronous; assign each connection attempt a generation so a late callback from a canceled attempt cannot overwrite a newer selection.

Discover GATT services as a staged transaction

After connecting, assign the peripheral’s delegate before requesting service discovery. Discover only the services relevant to the feature, then discover the required characteristics, and enable notifications only for characteristics that support them. Each step has a callback and can fail independently. Keep a small state enum such as connecting, discovering services, discovering characteristics, ready, and failed rather than compressing the workflow into one Boolean.

The peripheral’s identifier is useful for the local Core Bluetooth workflow, but it is not a cryptographic proof of the physical device or a globally stable product identity. If the app needs a trusted device identity, define an application-level enrollment protocol and authenticate the data at that layer. Do not grant sensitive account access because an advertisement name or service UUID matches.

Characteristic reads and writes are asynchronous and subject to the peripheral’s supported properties and response mode. Check properties before choosing a read, write-with-response, or write-without-response path. For larger payloads, respect the transport’s negotiated limits and maintain application-level framing, sequence, and integrity checks. Do not assume one callback equals one logical message.

Serialize operations according to the app protocol. If a feature reads configuration, subscribes to notifications, and then writes a command, define those dependencies and advance only after each relevant delegate callback. Sending a burst of requests without tracking which response belongs to which stage makes timeouts difficult to recover from. Add an operation identifier and deadline to the app’s request model, but do not assume the peripheral echoes that identifier unless the protocol defines it.

Notification subscription is a stream of changing values, not a durable database. Persist an update only after validating its format and deciding whether duplicate or out-of-order values are possible. If the characteristic encodes a counter, timestamp, or sequence, use it to detect stale samples. If it has no such field, do not invent ordering guarantees from callback arrival alone. Surface the timestamp of last valid data and distinguish “connected” from “currently receiving useful updates.”

For user-facing scans, define an energy and privacy budget: narrow service filters, stop when the user leaves discovery, and avoid storing every nearby device. RSSI fluctuates with distance, orientation, antenna behavior, and environment; do not use one reading as a precise range measurement. Debounce candidate presentation and require explicit selection for device-specific actions. The framework can discover a peer, while only the application protocol can establish that the peer is the intended product and is ready for the operation.

Disconnects, stale callbacks, and restoration

Connections can end because the peripheral moves out of range, powers off, resets, or the app cancels. On disconnection, clear service and characteristic handles for that connection generation, cancel timers, and update the UI. Reconnection should be an explicit policy, not an automatic infinite retry. If the user is actively using a feature that benefits from recovery, retry with backoff and a clear cancel path.

Core Bluetooth supports state restoration when an app opts into the relevant manager restoration behavior. Treat restoration as rehydrating an interrupted workflow, not as evidence that the app’s in-memory state survived. Reconcile the restored manager/peripheral state against the user’s current intent and the app’s own records. If state restoration is not configured, rebuild the connection from normal discovery and report progress accurately.

Do not keep a stale CBPeripheral as the sole source of truth after a manager reset or a new discovery generation. Track whether the selected device is connected, connecting, or merely known locally. A late notification from a prior attempt should be rejected using the attempt identity before it changes UI or persistent state.

Data, privacy, and diagnostics

Bluetooth traffic can contain personal or operational data. Collect only required services, keep raw advertisement payloads out of routine logs, and avoid exposing nearby device names in analytics. Persist only the identifier and metadata needed for a user-visible feature, and define how a user removes a remembered peripheral.

Useful diagnostics include manager state transitions, scan start/stop reason, connection attempt generation, service discovery result, characteristic operation type, error domain and code, and elapsed time. Avoid logging characteristic values by default. For intermittent problems, correlate a short diagnostic session with the user’s action and preserve whether the manager was powered on, authorized, and connected at each step.

Acceptance tests

Test manager startup from unknown, radio off/on, permission denial and later grant, scan with no results, duplicate advertisements, candidate selection, connect failure, missing service, missing characteristic, notification subscription, characteristic write error, peripheral disconnect, app cancellation, and a late callback after a new attempt begins. Include a device that changes its advertised data and a workflow that runs without Bluetooth permission.

Measure time to first candidate, scan duration, connection latency, service-discovery latency, reconnect attempts, and time from user cancel to scan stop. A robust central workflow waits for manager state, limits radio work, models GATT stages, and distinguishes a remembered peripheral from a reachable and authenticated product device.

Related:

Sources:

Comments