← All posts

iOS · Bluetooth

How iOS Bluetooth Low Energy (BLE) Works: A CoreBluetooth Guide

Roles, scanning, GATT, notifications, background modes and state restoration — plus a small Combine wrapper. Written from my experience building BLE connectivity in an iOS app.

By Markandeyulu Nistala··9 min read CoreBluetoothSwiftCombine

Why I wrote this

I’m an iOS developer, and at Innominds I work on Royal Enfield’s Flying Flea app. Part of that work is Bluetooth Low Energy (BLE): scanning for the device, managing the connection, and using that link to drive real-time vehicle dashboard updates in the app, alongside push notifications and network monitoring built with Combine.

BLE looks simple in the sample code and gets subtle quickly in a real product — permissions, background behaviour, reconnection, threading. This post is the mental model I wish I’d had on day one. I can’t share anything about the actual vehicle protocol, so every UUID and payload below is a generic placeholder.

Heads-up. CoreBluetooth does not work in the iOS Simulator. You need a real iPhone or iPad and a real peripheral (or a phone/laptop running a tool such as nRF Connect to emulate one).

BLE in one minute

BLE is a low-power radio protocol designed for small, infrequent bursts of data. Two layers matter to an app developer:

  • GAP (Generic Access Profile) — how devices find and connect to each other. A peripheral broadcasts small advertising packets; a central scans for them and can initiate a connection.
  • GATT (Generic Attribute Profile) — how data is organised once connected. A peripheral exposes a tree of services, each containing characteristics (the actual values), which may have descriptors. Everything is identified by a UUID (16-bit for Bluetooth-SIG-defined items, 128-bit for custom ones).

On iOS your app is normally the central (CBCentralManager) and the hardware — a wearable, a sensor, a vehicle — is the peripheral. iOS can also act as a peripheral (CBPeripheralManager), but that is a different, more limited use case and not what this post covers.

Each characteristic advertises what you can do with it through its properties:

PropertyWhat it meansCoreBluetooth call
readAsk for the current value oncereadValue(for:)
writeWrite with a confirmation from the peripheralwriteValue(_:for:type: .withResponse)
writeWithoutResponseFire-and-forget write; higher throughputwriteValue(_:for:type: .withoutResponse)
notify / indicatePeripheral pushes new values to you (indicate is acknowledged at the BLE layer, notify is not)setNotifyValue(true, for:)

For something like a live dashboard, notifications are the workhorse: you subscribe once and the peripheral pushes updates whenever the value changes, so you never poll.

The connection flow

Every BLE session follows the same sequence. Knowing where you are in it makes debugging much easier:

  1. Check stateWait for poweredOn and authorization
  2. ScanFilter by service UUID
  3. DiscoverdidDiscover with RSSI + ad data
  4. ConnectKeep a strong ref to the peripheral
  5. Discover GATTServices, then characteristics
  6. SubscribesetNotifyValue / read / write
  7. Handle lossDisconnect → reconnect
Your appCBCentralManagerCoreBluetoothiOSPeripheralthe devicescanForPeripherals(withServices:)advertising packetsdidDiscover(peripheral, RSSI)connect(peripheral)didConnectdiscoverServices / CharacteristicsdidDiscoverCharacteristicsForsetNotifyValue(true, for:)didUpdateValueFor (notifications)
A typical central-role session as seen from the app.

Permissions and Info.plist

Since iOS 13, any app that uses CoreBluetooth must declare NSBluetoothAlwaysUsageDescription in its Info.plist. Without it the app crashes the first time you create a manager. The string is shown in the system permission prompt, so write it for users, not developers.

If the app needs to keep a connection alive or react to the device while backgrounded, add the bluetooth-central value to UIBackgroundModes (the “Uses Bluetooth LE accessories” capability in Xcode).

<key>NSBluetoothAlwaysUsageDescription</key>
<string>Bluetooth is used to find and connect to your device.</string>

<!-- Only if the app must keep working in the background -->
<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
</array>

Creating a CBCentralManager is what triggers the permission prompt, so create it when you’re ready to explain why you need Bluetooth (not necessarily at launch). You can check the current answer with CBCentralManager.authorization, and you must handle .unauthorized and .poweredOff in centralManagerDidUpdateState(_:) — send the user to Settings instead of silently failing.

Scanning and connecting

You can only scan once the manager’s state is .poweredOn. Always pass service UUIDs to scanForPeripherals(withServices:options:): it saves battery, reduces noise, and is required for scanning in the background (a nil filter returns nothing there).

import CoreBluetooth

final class Scanner: NSObject, CBCentralManagerDelegate {
    private var central: CBCentralManager!
    private var target: CBPeripheral?            // keep a STRONG reference
    private let serviceUUID = CBUUID(string: "FFF0")   // placeholder

    override init() {
        super.init()
        // Creating the manager is what triggers the permission prompt.
        central = CBCentralManager(delegate: self, queue: nil)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            central.scanForPeripherals(withServices: [serviceUUID],
                                       options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
        case .unauthorized, .poweredOff, .unsupported, .resetting, .unknown:
            break // surface this in the UI; don't scan
        @unknown default:
            break
        }
    }

    func centralManager(_ central: CBCentralManager,
                        didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any],
                        rssi RSSI: NSNumber) {
        guard RSSI.intValue > -80 else { return }   // ignore very weak signals
        central.stopScan()                           // scanning drains battery
        target = peripheral
        central.connect(peripheral, options: nil)
    }
}
  • Retain the peripheral. CoreBluetooth does not keep your CBPeripheral alive. If you don’t store a strong reference, it is deallocated and the connection attempt silently goes nowhere. This is the #1 beginner bug.
  • Stop scanning once you have what you need. Continuous scanning is a battery drain.
  • connect(_:options:) never times out. It stays pending until the device appears or you cancel. Run your own timer if the UI needs a failure after N seconds, then call cancelPeripheralConnection(_:).
  • Don’t trust the name. Local names in advertisements are optional and spoofable. Filter by service UUID and, if you need to pick between several devices, by the advertisement’s manufacturer data or by RSSI proximity.
  • The peripheral identifier is not a MAC address. iOS hides MAC addresses; peripheral.identifier is a UUID that iOS generates per device pair. Store it to reconnect later with retrievePeripherals(withIdentifiers:), but don’t treat it as a permanent hardware ID.

Discovering GATT, reading, writing and notifications

After didConnect, set the peripheral’s delegate and walk the GATT tree: services first, then characteristics for each service. Discover only the UUIDs you need — a full discovery takes extra round-trips on every connect.

extension Scanner: CBPeripheralDelegate {
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        peripheral.delegate = self
        peripheral.discoverServices([serviceUUID])           // only what you need
    }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard error == nil else { return }
        for service in peripheral.services ?? [] {
            peripheral.discoverCharacteristics(nil, for: service)
        }
    }

    func peripheral(_ peripheral: CBPeripheral,
                    didDiscoverCharacteristicsFor service: CBService,
                    error: Error?) {
        for ch in service.characteristics ?? [] {
            if ch.properties.contains(.notify) {
                peripheral.setNotifyValue(true, for: ch)     // subscribe
            }
            if ch.properties.contains(.read) {
                peripheral.readValue(for: ch)                // one-off read
            }
        }
    }

    func peripheral(_ peripheral: CBPeripheral,
                    didUpdateValueFor characteristic: CBCharacteristic,
                    error: Error?) {
        guard error == nil, let data = characteristic.value else { return }
        // Called for both read responses and notifications.
        print("\(characteristic.uuid): \(data as NSData)")
    }

    func write(_ data: Data, to ch: CBCharacteristic, on p: CBPeripheral) {
        if ch.properties.contains(.write) {
            p.writeValue(data, for: ch, type: .withResponse)   // confirmed in didWriteValueFor
        } else if p.canSendWriteWithoutResponse {
            p.writeValue(data, for: ch, type: .withoutResponse)
        }
    }
}
  • Notifications: setNotifyValue(true, for:) writes the Client Characteristic Configuration Descriptor for you. Wait for didUpdateNotificationStateFor if you need to know it succeeded. Values then arrive in didUpdateValueFor — the same callback used for reads, so check characteristic.uuid.
  • Write sizes: use peripheral.maximumWriteValueLength(for:) to chunk larger payloads. iOS negotiates the MTU itself; you can’t set it.
  • Write without response: check canSendWriteWithoutResponse and resume in peripheralIsReady(toSendWriteWithoutResponse:) when the queue frees up, or writes can be dropped.
  • Byte order and framing are defined by your device’s firmware. Parse defensively: validate length first, then decode with Data and explicit endianness.

Reconnection that actually works

Radio links drop: the user walks away, the device sleeps, the phone toggles Bluetooth. A robust app treats disconnection as a normal event.

  • In didDisconnectPeripheral you’ll often get an error (timeout, peer removed pairing, etc.). Log it, update the UI, then decide whether to retry.
  • Calling connect(_:) again right away is a cheap and efficient auto-reconnect. Because the call never times out, iOS keeps listening for that device and connects the moment it’s back — even while your app is suspended, if you have the background mode.
  • Rediscover services after every reconnect — don’t assume cached CBCharacteristic objects are still valid. Clear your caches in the disconnect callback.
  • On a fresh app launch, try retrievePeripherals(withIdentifiers:) and retrieveConnectedPeripherals(withServices:) before scanning; the device may already be known or connected.
  • Use registerForConnectionEvents(options:) (iOS 13+) if you want the system to tell you when a matching device connects or disconnects.
  • Back off if retries keep failing, and expose a manual “retry” action instead of looping forever.

Background mode and state restoration

With bluetooth-central enabled, your app is woken briefly in the background for BLE events: a connection, a disconnection, or a notification. Background scanning is more constrained than foreground scanning — you must pass service UUIDs, and discoveries of the same device are coalesced into fewer callbacks. Do only short, quick work in these wake-ups.

If the system terminates your app while it’s holding a connection or scan, state preservation and restoration lets iOS relaunch it in the background when something relevant happens. Opt in by passing a restore identifier when creating the manager, then implement willRestoreState:

let central = CBCentralManager(
    delegate: self,
    queue: bleQueue,
    options: [CBCentralManagerOptionRestoreIdentifierKey: "com.example.ble.central"]
)

func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
    let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
    for p in peripherals {
        p.delegate = self            // re-attach your delegate
        self.target = p              // and your strong reference
    }
    // willRestoreState runs before centralManagerDidUpdateState;
    // finish setup (re-subscribe, resume scan) once the state is .poweredOn.
}
Limits. Restoration helps when the system kills your app for memory or other reasons. It does not relaunch an app the user has force-quit from the app switcher. Always create the manager with the same restore identifier early in launch so the system can deliver the event.

A Combine wrapper

The delegate API is callback-heavy. I like to hide it behind one small class that owns the CBCentralManager, a single serial queue and the strong peripheral reference, and exposes Combine publishers to the rest of the app. This is a trimmed, generic version of the pattern (placeholder UUIDs, no real protocol):

import CoreBluetooth
import Combine

final class BLEClient: NSObject {
    enum Link { case idle, scanning, connecting, discovering, ready, disconnected(Error?) }

    // MARK: Public streams (emit on the BLE queue — hop to main in the UI layer)
    var state: AnyPublisher<CBManagerState, Never>   { stateSubject.eraseToAnyPublisher() }
    var link: AnyPublisher<Link, Never>              { linkSubject.eraseToAnyPublisher() }
    func notifications(for uuid: CBUUID) -> AnyPublisher<Data, Never> {
        valueSubject.filter { $0.0 == uuid }.map(\.1).eraseToAnyPublisher()
    }

    private let stateSubject = CurrentValueSubject<CBManagerState, Never>(.unknown)
    private let linkSubject  = CurrentValueSubject<Link, Never>(.idle)
    private let valueSubject = PassthroughSubject<(CBUUID, Data), Never>()

    private let queue = DispatchQueue(label: "ble.central")
    private lazy var central = CBCentralManager(
        delegate: self, queue: queue,
        options: [CBCentralManagerOptionRestoreIdentifierKey: "com.example.ble.central"])

    private var peripheral: CBPeripheral?                 // strong reference
    private var characteristics: [CBUUID: CBCharacteristic] = [:]
    private var wantsConnection = false
    private let serviceUUID = CBUUID(string: "FFF0")      // placeholder

    func start() { queue.async { _ = self.central; self.wantsConnection = true; self.scanIfReady() } }
    func stop()  { queue.async {
        self.wantsConnection = false
        self.central.stopScan()
        if let p = self.peripheral { self.central.cancelPeripheralConnection(p) }
    } }

    func write(_ data: Data, to uuid: CBUUID) {
        queue.async {
            guard let p = self.peripheral, let ch = self.characteristics[uuid] else { return }
            p.writeValue(data, for: ch, type: .withResponse)
        }
    }

    private func scanIfReady() {
        guard central.state == .poweredOn, wantsConnection, peripheral == nil else { return }
        linkSubject.send(.scanning)
        central.scanForPeripherals(withServices: [serviceUUID])
    }
}

extension BLEClient: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ c: CBCentralManager) {
        stateSubject.send(c.state)
        scanIfReady()
    }

    func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral,
                        advertisementData: [String: Any], rssi RSSI: NSNumber) {
        c.stopScan()
        peripheral = p
        linkSubject.send(.connecting)
        c.connect(p)
    }

    func centralManager(_ c: CBCentralManager, didConnect p: CBPeripheral) {
        p.delegate = self
        linkSubject.send(.discovering)
        p.discoverServices([serviceUUID])
    }

    func centralManager(_ c: CBCentralManager, didDisconnectPeripheral p: CBPeripheral, error: Error?) {
        characteristics.removeAll()
        linkSubject.send(.disconnected(error))
        if wantsConnection { c.connect(p) }   // pending connect = free auto-reconnect
    }

    func centralManager(_ c: CBCentralManager, willRestoreState dict: [String: Any]) {
        if let p = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral])?.first {
            peripheral = p; p.delegate = self
        }
    }
}

extension BLEClient: CBPeripheralDelegate {
    func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
        p.services?.forEach { p.discoverCharacteristics(nil, for: $0) }
    }

    func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor s: CBService, error: Error?) {
        for ch in s.characteristics ?? [] {
            characteristics[ch.uuid] = ch
            if ch.properties.contains(.notify) { p.setNotifyValue(true, for: ch) }
        }
        linkSubject.send(.ready)
    }

    func peripheral(_ p: CBPeripheral, didUpdateValueFor ch: CBCharacteristic, error: Error?) {
        guard error == nil, let data = ch.value else { return }
        valueSubject.send((ch.uuid, data))
    }
}

Then a SwiftUI view model just subscribes — and because notification streams can be chatty, it’s worth removing duplicates and throttling before you touch @Published properties:

@MainActor
final class DashboardViewModel: ObservableObject {
    @Published var speedText = "—"
    @Published var isConnected = false
    private var bag = Set<AnyCancellable>()

    init(ble: BLEClient, telemetry: CBUUID) {
        ble.notifications(for: telemetry)
            .compactMap { $0.first }                       // parse your own payload here
            .map { "\($0)" }
            .removeDuplicates()
            .throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
            .assign(to: &$speedText)

        ble.link
            .map { if case .ready = $0 { true } else { false } }
            .receive(on: DispatchQueue.main)
            .assign(to: &$isConnected)
    }
}

A few design notes: the BLE queue is separate from the main queue so scanning and parsing never block the UI; publishers emit on that queue, and the view model hops to main; the wrapper owns reconnection so views never see raw CoreBluetooth types.

Checklist and takeaways

  • Create one central manager, with a dedicated serial queue and a restore identifier if you need background support.
  • Declare NSBluetoothAlwaysUsageDescription; add bluetooth-central only if you truly need background behaviour.
  • Scan with service UUIDs, stop as soon as you’re done, and keep a strong reference to the CBPeripheral.
  • Implement your own connect timeout; treat disconnects as normal and re-discover services after reconnecting.
  • Subscribe to notifications instead of polling; throttle and de-duplicate before updating UI.
  • Test on real hardware, with the device in poor conditions: out of range, powered off mid-session, Bluetooth toggled, app killed and relaunched.

If you’re building something with BLE on iOS and want to compare notes, feel free to get in touch.