Core Bluetooth: Connecting to BLE Accessories and IoT Devices


You have shipped an app that talks to a REST API. Now the product team wants you to connect to a heart-rate chest strap, a custom IoT sensor, or a Bluetooth-enabled toy — and suddenly your reliable URLSession skills are useless. Bluetooth Low Energy (BLE) has its own state machine, its own permission model, and its own set of hard-to-debug failure modes that will humble even seasoned iOS engineers.

This post covers the full lifecycle of a Core Bluetooth central-role connection: scanning, connecting, discovering services and characteristics, reading and writing values, subscribing to notifications, and cleaning up gracefully. We will also look at AccessorySetupKit, introduced in iOS 18, which replaces the old Bluetooth permission prompt with a privacy-preserving picker. We will not cover the peripheral role (acting as a BLE server) or classic Bluetooth — those are separate topics.

This guide assumes you are comfortable with async/await and protocols.

Contents

The Problem

Imagine you are building an app for Pixar’s Toy Story universe. Each toy has an embedded BLE chip that broadcasts its identity, battery level, and current catchphrase. The app needs to discover nearby toys, connect to them, read their data, and subscribe to real-time updates when Woody shouts “There’s a snake in my boot!” or Buzz switches to Spanish mode.

A naive first attempt looks something like this:

import CoreBluetooth

final class ToyFinderBroken: NSObject {
    let central = CBCentralManager()

    func findToys() {
        // This will crash or silently fail.
        // CBCentralManager must be powered on before scanning.
        central.scanForPeripherals(withServices: nil)
    }
}

This code has three problems. First, the manager has no delegate, so you never learn when Bluetooth is ready. Second, scanning with nil services returns every BLE device in range — an expensive, battery-draining operation. Third, there is no reference retained to discovered peripherals, so the system will deallocate them before you can connect.

Core Bluetooth is a delegate-heavy, stateful framework. You must respect its state machine or you get silence at best and crashes at worst.

Core Bluetooth Architecture in 60 Seconds

The framework is built around two roles. A central scans for and connects to peripherals. A peripheral advertises services and responds to requests. Most iOS apps act as centrals.

Every peripheral exposes a hierarchy:

  • Peripheral — the remote device (e.g., Woody’s BLE chip).
  • Service — a logical grouping of functionality, identified by a UUID (e.g., ToyIdentity service).
  • Characteristic — a single data point within a service (e.g., catchphrase, batteryLevel). Characteristics have properties: .read, .write, .notify, .indicate, and others.

Apple Docs: Core Bluetooth Overview — Core Bluetooth framework

UUIDs follow the Bluetooth SIG standard. You will typically define custom 128-bit UUIDs for proprietary services:

enum ToyBLEConstants {
    static let toyServiceUUID = CBUUID(string: "A1B2C3D4-E5F6-7890-ABCD-EF1234567890")
    static let catchphraseCharUUID = CBUUID(string: "A1B2C3D4-E5F6-7890-ABCD-EF1234567891")
    static let batteryCharUUID = CBUUID(string: "A1B2C3D4-E5F6-7890-ABCD-EF1234567892")
    static let modeWriteCharUUID = CBUUID(string: "A1B2C3D4-E5F6-7890-ABCD-EF1234567893")
}

Building a BLE Central Manager

The central manager is the entry point for all scanning and connection operations. You must hold a strong reference to it, set a delegate, and wait for the .poweredOn state before doing anything.

import CoreBluetooth
import os

final class ToyScanner: NSObject, ObservableObject {
    private let logger = Logger(subsystem: "com.pixar.toybox", category: "BLE")
    private var centralManager: CBCentralManager!
    private var discoveredPeripherals: [CBPeripheral] = []

    @Published var connectedToy: CBPeripheral?
    @Published var catchphrase: String = ""
    @Published var batteryLevel: Int = 0

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }
}

A few decisions worth noting. We use queue: nil to receive delegate callbacks on the main queue, which is convenient for driving UI. In a production app handling high-throughput data (firmware updates, streaming sensor data), pass a dedicated DispatchQueue instead. We store discoveredPeripherals in an array because Core Bluetooth does not retain discovered peripherals — if you do not keep a strong reference, they get deallocated and the connection silently fails.

Now implement CBCentralManagerDelegate to respond to state changes:

extension ToyScanner: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            logger.info("Bluetooth is powered on. Ready to scan.")
            startScanning()
        case .poweredOff:
            logger.warning("Bluetooth is powered off.")
        case .unauthorized:
            logger.error("Bluetooth permission denied.")
        case .unsupported:
            logger.error("BLE is not supported on this device.")
        case .resetting:
            logger.warning("Bluetooth is resetting. Will retry on next state update.")
        case .unknown:
            logger.info("Bluetooth state is unknown. Waiting...")
        @unknown default:
            logger.warning("Unhandled Bluetooth state: \(central.state.rawValue)")
        }
    }
}

Warning: Never call scanForPeripherals before receiving .poweredOn. The framework will silently ignore the call, and you will spend hours wondering why nothing shows up.

Discovering and Connecting to Peripherals

Once the manager is powered on, scan for peripherals advertising your specific service UUID:

extension ToyScanner {
    func startScanning() {
        guard centralManager.state == .poweredOn else { return }

        centralManager.scanForPeripherals(
            withServices: [ToyBLEConstants.toyServiceUUID],
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )
        logger.info("Scanning for Toy Service peripherals...")
    }

    func stopScanning() {
        centralManager.stopScan()
        logger.info("Scanning stopped.")
    }
}

Always pass a specific service UUID array. Scanning with nil returns every BLE device in range — your user’s AirPods, their neighbor’s smart lock, that random fitness band — and drains battery aggressively.

When a peripheral is discovered, retain it and connect:

extension ToyScanner {
    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        let name = peripheral.name ?? "Unknown Toy"
        logger.info("Discovered \(name) with RSSI \(RSSI)")

        // Retain the peripheral -- Core Bluetooth does not.
        if !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) {
            discoveredPeripherals.append(peripheral)
        }

        // Connect to the first toy found. In production, present a picker.
        stopScanning()
        centralManager.connect(peripheral, options: nil)
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        logger.info("Connected to \(peripheral.name ?? "Unknown")")
        connectedToy = peripheral
        peripheral.delegate = self
        peripheral.discoverServices([ToyBLEConstants.toyServiceUUID])
    }

    func centralManager(
        _ central: CBCentralManager,
        didFailToConnect peripheral: CBPeripheral,
        error: Error?
    ) {
        logger.error("Failed to connect: \(error?.localizedDescription ?? "Unknown error")")
        discoveredPeripherals.removeAll { $0.identifier == peripheral.identifier }
    }
}

After a successful connection, you must discover services. After discovering services, discover characteristics. It is a sequential, waterfall-style flow dictated by the delegate pattern.

extension ToyScanner: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        if let error {
            logger.error("Service discovery failed: \(error.localizedDescription)")
            return
        }

        guard let services = peripheral.services else { return }
        for service in services where service.uuid == ToyBLEConstants.toyServiceUUID {
            peripheral.discoverCharacteristics(
                [ToyBLEConstants.catchphraseCharUUID,
                 ToyBLEConstants.batteryCharUUID,
                 ToyBLEConstants.modeWriteCharUUID],
                for: service
            )
        }
    }

    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService,
        error: Error?
    ) {
        if let error {
            logger.error("Characteristic discovery failed: \(error.localizedDescription)")
            return
        }

        guard let characteristics = service.characteristics else { return }
        for characteristic in characteristics {
            switch characteristic.uuid {
            case ToyBLEConstants.catchphraseCharUUID:
                peripheral.readValue(for: characteristic)
                peripheral.setNotifyValue(true, for: characteristic)
            case ToyBLEConstants.batteryCharUUID:
                peripheral.readValue(for: characteristic)
            case ToyBLEConstants.modeWriteCharUUID:
                // Write-only, no initial read needed.
                break
            default:
                break
            }
        }
    }
}

Tip: Always specify the exact characteristic UUIDs you need in discoverCharacteristics(_:for:). Passing nil discovers all characteristics on the service, which is slower and wastes radio time.

Reading, Writing, and Subscribing to Characteristics

Once characteristics are discovered, you interact with them through three operations: read, write, and notify/indicate.

Reading Values

Reads are asynchronous. You call readValue(for:) and the result arrives in a delegate callback:

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    if let error {
        logger.error("Read failed for \(characteristic.uuid): \(error.localizedDescription)")
        return
    }

    guard let data = characteristic.value else { return }

    switch characteristic.uuid {
    case ToyBLEConstants.catchphraseCharUUID:
        if let phrase = String(data: data, encoding: .utf8) {
            DispatchQueue.main.async { [weak self] in
                self?.catchphrase = phrase
            }
            logger.info("Catchphrase updated: \(phrase)")
        }
    case ToyBLEConstants.batteryCharUUID:
        let level = Int(data.first ?? 0)
        DispatchQueue.main.async { [weak self] in
            self?.batteryLevel = level
        }
        logger.info("Battery level: \(level)%")
    default:
        break
    }
}

This same delegate method fires for both explicit reads and notification updates. The characteristic.uuid tells you which value changed.

Writing Values

Writing comes in two flavors. .withResponse waits for the peripheral to acknowledge, and .withoutResponse is fire-and-forget (faster but no delivery guarantee):

extension ToyScanner {
    /// Sends a mode command to the toy. For example, switching Buzz to Spanish mode.
    func writeToyMode(_ mode: UInt8) {
        guard let peripheral = connectedToy,
              let service = peripheral.services?.first(where: { $0.uuid == ToyBLEConstants.toyServiceUUID }),
              let characteristic = service.characteristics?.first(where: { $0.uuid == ToyBLEConstants.modeWriteCharUUID })
        else {
            logger.warning("Cannot write: toy not connected or characteristic not found.")
            return
        }

        let data = Data([mode])
        let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.write)
            ? .withResponse
            : .withoutResponse
        peripheral.writeValue(data, for: characteristic, type: writeType)
        logger.info("Wrote mode \(mode) to toy.")
    }
}

Tip: Check characteristic.properties before writing. Attempting .withResponse on a characteristic that only supports .writeWithoutResponse will trigger a delegate error.

Subscribing to Notifications

We already called peripheral.setNotifyValue(true, for: characteristic) during discovery. The confirmation arrives in another delegate method:

func peripheral(
    _ peripheral: CBPeripheral,
    didUpdateNotificationStateFor characteristic: CBCharacteristic,
    error: Error?
) {
    if let error {
        logger.error("Notification subscription failed: \(error.localizedDescription)")
        return
    }
    logger.info("Notifications \(characteristic.isNotifying ? "enabled" : "disabled") for \(characteristic.uuid)")
}

Once subscribed, every time the peripheral updates the characteristic value, didUpdateValueFor fires automatically. This is how you get real-time updates — when Woody’s catchphrase changes from “Reach for the sky!” to “You’re my favorite deputy!” your app reflects it instantly.

AccessorySetupKit: The Modern Permission Model

Before iOS 18, connecting to a BLE device required the user to grant blanket Bluetooth permission via NSBluetoothAlwaysUsageDescription. This gave your app access to every BLE device in range — a legitimate privacy concern.

AccessorySetupKit, introduced in iOS 18, replaces this with a focused picker. The user selects a specific accessory, and your app only gets access to that one device. No broad permission prompt, no background scanning of unrelated devices.

import AccessorySetupKit

@available(iOS 18.0, *)
final class ToyAccessorySetup: ObservableObject {
    private let session = ASAccessorySession()
    @Published var selectedAccessory: ASAccessory?

    func presentPicker() {
        let descriptor = ASDiscoveryDescriptor()
        descriptor.bluetoothServiceUUID = ToyBLEConstants.toyServiceUUID

        session.activate(on: .main) { [weak self] event in
            self?.handleSessionEvent(event)
        }

        session.showPicker(for: [descriptor]) { error in
            if let error {
                print("Picker failed: \(error.localizedDescription)")
            }
        }
    }

    private func handleSessionEvent(_ event: ASAccessoryEvent) {
        switch event.eventType {
        case .activated:
            print("Session activated.")
        case .accessoryAdded:
            if let accessory = event.accessory {
                selectedAccessory = accessory
                print("Accessory added: \(accessory.displayName)")
            }
        case .accessoryRemoved:
            selectedAccessory = nil
        case .accessoryChanged:
            selectedAccessory = event.accessory
        default:
            break
        }
    }
}

After the user selects an accessory through the picker, you use its bluetoothIdentifier to connect via CBCentralManager as before. The key difference is that you no longer need the NSBluetoothAlwaysUsageDescription key for this flow — the picker itself serves as the permission gate.

Note: AccessorySetupKit requires iOS 18+. If you support older iOS versions, you still need the traditional CBCentralManager permission flow as a fallback.

Advanced Usage

State Restoration for Background BLE

If your app needs to maintain BLE connections while backgrounded (a fitness tracker that keeps logging heart rate), you must enable state restoration:

// Initialize with a restoration identifier.
centralManager = CBCentralManager(
    delegate: self,
    queue: bleQueue,
    options: [CBCentralManagerOptionRestoreStateIdentifierKey: "com.pixar.toybox.central"]
)

// Implement the restoration delegate method.
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
    if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
        for peripheral in peripherals {
            peripheral.delegate = self
            discoveredPeripherals.append(peripheral)
            if peripheral.state == .connected {
                connectedToy = peripheral
                peripheral.discoverServices([ToyBLEConstants.toyServiceUUID])
            }
        }
    }
}

You also need the bluetooth-central background mode in your Info.plist:

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
</array>

Warning: Background BLE scanning is heavily throttled by iOS. Scan intervals become much longer, and CBCentralManagerScanOptionAllowDuplicatesKey is ignored. Design your peripheral’s advertising interval accordingly (Apple recommends 20ms to 152.5ms for best discovery performance).

Wrapping Delegates in async/await

The delegate waterfall in Core Bluetooth is one of its most painful aspects. You can wrap individual operations in continuations for cleaner call sites:

extension ToyScanner {
    func readCatchphrase(from peripheral: CBPeripheral) async throws -> String {
        guard let service = peripheral.services?.first(where: { $0.uuid == ToyBLEConstants.toyServiceUUID }),
              let characteristic = service.characteristics?.first(where: { $0.uuid == ToyBLEConstants.catchphraseCharUUID })
        else {
            throw ToyBLEError.characteristicNotFound
        }

        return try await withCheckedThrowingContinuation { continuation in
            self.pendingReadContinuation = continuation
            peripheral.readValue(for: characteristic)
        }
    }
}

enum ToyBLEError: Error {
    case characteristicNotFound
    case readFailed(String)
    case connectionLost
}

This approach works but has a gotcha: you can only have one pending continuation per characteristic at a time. If you call readCatchphrase twice before the first completes, the first continuation leaks. Use a dictionary keyed by CBUUID to manage multiple in-flight operations, or serialize reads through an AsyncStream.

Handling Disconnections

Disconnections happen. The toy runs out of battery, the user walks out of range, or iOS kills your background connection. Always implement the disconnect delegate:

func centralManager(
    _ central: CBCentralManager,
    didDisconnectPeripheral peripheral: CBPeripheral,
    error: Error?
) {
    logger.warning("Disconnected from \(peripheral.name ?? "Unknown"): \(error?.localizedDescription ?? "No error")")
    connectedToy = nil

    // Attempt reconnection for known peripherals.
    if discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) {
        centralManager.connect(peripheral, options: nil)
        logger.info("Attempting reconnection...")
    }
}

Tip: CBCentralManager.connect has no timeout. If the peripheral never comes back in range, the pending connection lives forever. Use a timer or DispatchWorkItem to cancel stale connection attempts after a reasonable period (30-60 seconds).

Performance Considerations

BLE communication has real costs that you must account for in production:

Radio time is battery time. Every scan, read, write, and notification uses the Bluetooth radio. Scan only when necessary and stop as soon as you find your target. Use notification subscriptions instead of polling with repeated reads.

MTU negotiation matters. The default BLE MTU is 23 bytes (20 bytes payload). iOS automatically negotiates a larger MTU (up to 512 bytes) when both sides support it. For large payloads like firmware updates, check peripheral.maximumWriteValueLength(for:) and chunk your data accordingly.

Connection interval affects latency. iOS manages connection intervals internally (typically 15ms to 2 seconds). You cannot set this from the central side, but the peripheral’s firmware can request a preferred interval. Lower intervals mean faster throughput but higher battery drain on both sides.

Service discovery is cached. After the first successful discovery, iOS caches the service and characteristic hierarchy. Subsequent connections skip the discovery round-trip. However, if the peripheral’s GATT database changes (firmware update), the cache becomes stale. Call peripheral.discoverServices with nil to force a fresh discovery when you know the firmware was updated.

Apple Docs: Core Bluetooth Best Practices — Core Bluetooth Programming Guide

When to Use (and When Not To)

ScenarioRecommendation
Custom BLE hardware (fitness tracker, IoT sensor, toy)Core Bluetooth is the right tool. You need full GATT control.
Standard Bluetooth accessories (headphones, keyboards)Do not use Core Bluetooth. These use classic Bluetooth profiles handled by the system.
HomeKit-compatible accessoriesUse HomeKit instead. It abstracts the BLE layer.
Peer-to-peer between iOS devicesUse MultipeerConnectivity or Network framework. Core Bluetooth is overkill.
Simple accessory pairing (iOS 18+)Start with AccessorySetupKit, then drop down to CBCentralManager for data.
Beacons and proximity detectionUse Core Location iBeacon APIs, not Core Bluetooth directly.

Core Bluetooth gives you complete control over the BLE stack, but that control comes with complexity. If a higher-level framework (HomeKit, HealthKit, MultipeerConnectivity) solves your use case, prefer it. Core Bluetooth is for when you own the peripheral’s firmware or need to speak a custom GATT profile.

Summary

  • Core Bluetooth follows a strict state machine: wait for .poweredOn, scan with specific service UUIDs, retain discovered peripherals, and follow the service-to-characteristic discovery waterfall.
  • Always specify service and characteristic UUIDs when scanning and discovering. Passing nil wastes battery and radio time.
  • Use notifications (.notify) for real-time updates instead of polling with repeated reads.
  • AccessorySetupKit (iOS 18+) provides a privacy-first picker that eliminates the need for blanket Bluetooth permissions.
  • Enable state restoration with a restoration identifier if your app maintains BLE connections in the background.

If you are building a smart home app that communicates with accessories over BLE, check out HomeKit and Matter: Smart Home Accessory Control in iOS to see how Apple’s higher-level framework simplifies accessory management. For sensor data processing from wearables, Core Motion covers pedometers, accelerometers, and activity recognition on the device side.