Skip to main content

CoreBluetooth Reference

A plain-English dictionary for CoreBluetooth — what every confusingly-named class, concept, and quirk actually means, why it matters, and a concise code example for each.

The BLE API is full of names borrowed from the Bluetooth spec that mean nothing until you’ve read about 40 pages of protocol documentation. This glossary maps each term to what it actually does in your app.


Central

The device that initiates a connection — in almost every app you write, this is the iPhone. The word comes from the Bluetooth GATT topology diagram where it sits at the center of a hub-and-spoke model. It has nothing to do with “central processing” or being important.

You care because CBCentralManager is the object that owns scanning, connecting, and the overall BLE power state. Everything on the scanner side goes through it. You must hold a strong reference to it for as long as you need BLE — releasing it tears down all connections.

import CoreBluetooth

class BluetoothScanner: NSObject, CBCentralManagerDelegate {
    private var central: CBCentralManager!

    override init() {
        super.init()
        // Queue nil = main queue. Pass a background queue for high-throughput apps.
        central = CBCentralManager(delegate: self, queue: nil)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        guard central.state == .poweredOn else { return }
        central.scanForPeripherals(withServices: nil)
    }
}

Peripheral

The accessory — a heart rate monitor, a glucose meter, a pair of headphones, a custom embedded device you built. It sits at the spokes of the hub. The name comes from “peripheral device” in the sense of a computer peripheral (keyboard, mouse), not from “peripheral vision.”

You care because CBPeripheral is the object you get back from scanning and the one you call .readValue, .writeValue, and .setNotifyValue on. Crucially, you must also hold a strong reference to every CBPeripheral you want to keep connected — the central manager only holds a weak reference.

var connectedPeripheral: CBPeripheral?   // must live here, not in a callback closure

func centralManager(_ central: CBCentralManager,
                    didDiscover peripheral: CBPeripheral,
                    advertisementData: [String: Any],
                    rssi RSSI: NSNumber) {
    connectedPeripheral = peripheral     // retain it before connecting
    central.connect(peripheral)
}

A small radio broadcast that a peripheral transmits every few milliseconds before any connection is made. Think of it as a sign a shop puts in its window: “I’m here, here’s my name, here’s a hint of what I sell.” The word comes from the Bluetooth spec’s advertising channels (channels 37, 38, 39).

You care for two reasons. First, you can read data from the advertisement packet without ever connecting — useful for beacons, proximity sensors, and anything that just needs to broadcast a value. Second, the advertisementData dictionary has several unintuitive keys worth knowing.

func centralManager(_ central: CBCentralManager,
                    didDiscover peripheral: CBPeripheral,
                    advertisementData: [String: Any],
                    rssi RSSI: NSNumber) {

    // Human-readable name the device chose to broadcast
    let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String

    // Service UUIDs the peripheral is advertising (not all services — just the ones
    // it chose to advertise so scanners can filter without connecting)
    let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]

    // Raw manufacturer payload — first 2 bytes are company ID (little-endian)
    let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data

    // Whether the peripheral is willing to accept a connection right now
    let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
}

GATT

Generic Attribute Profile. The layer of the BLE stack that defines how data is structured once you’re connected. It’s a hierarchy: a device exposes Services, each Service contains Characteristics, and each Characteristic can have Descriptors. The word “generic” just means it’s not tied to any one device category.

You care because every CoreBluetooth operation after connecting is a GATT operation. The spec also defines a library of standard services and characteristics with fixed UUIDs (the “GATT spec”), so a heart rate monitor from any vendor uses the same UUID for its heart rate measurement characteristic.

// Standard GATT UUIDs for heart rate (defined in the spec)
let heartRateService       = CBUUID(string: "180D")
let heartRateMeasurement   = CBUUID(string: "2A37")
let bodySensorLocation     = CBUUID(string: "2A38")

// Scanning only for peripherals that advertise the heart rate service
central.scanForPeripherals(withServices: [heartRateService])

Service

A logical container for a group of related data points on a peripheral. A fitness tracker might expose a Heart Rate Service, a Battery Service, and a proprietary Firmware Update Service — all on the same device. The word “service” maps loosely to “feature set” not to a network service or microservice.

You care because after connecting, your first job is always to discover services (more on that word below). You cannot access any data without going through a service first. You can filter discoverServices to only the ones you need — discovering everything is slower and wastes power.

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    peripheral.delegate = self

    // Only discover the two services we actually need
    let needed = [heartRateService, CBUUID(string: "180F")]  // HR + Battery
    peripheral.discoverServices(needed)
}

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    peripheral.services?.forEach { service in
        peripheral.discoverCharacteristics(nil, for: service)
    }
}

Characteristic

A single data point or control point inside a service. Think of a service as a table and a characteristic as a column. Each characteristic has a UUID, a value (raw Data), a set of properties (can you read it? write to it? subscribe to notifications?), and optionally, descriptors.

You care because characteristics are where the actual work happens — reading sensor data, writing commands, subscribing to live updates. The properties bitmask tells you what operations are legal; calling an unsupported operation gives you an error in the delegate callback, not a compile error.

func peripheral(_ peripheral: CBPeripheral,
                didDiscoverCharacteristicsFor service: CBService,
                error: Error?) {
    service.characteristics?.forEach { char in
        let props = char.properties
        if props.contains(.read)   { peripheral.readValue(for: char) }
        if props.contains(.notify) { peripheral.setNotifyValue(true, for: char) }
        if props.contains(.write)  { /* can write with response */ }
        if props.contains(.writeWithoutResponse) { /* can write without ack */ }
    }
}

Descriptor

Metadata about a characteristic — not the characteristic’s value itself, but information describing it. The most common descriptor is the Client Characteristic Configuration Descriptor (CCCD), which is the actual on-device toggle that enables or disables notifications. The name sounds like a developer-facing annotation but it lives on the peripheral.

You rarely interact with descriptors directly. setNotifyValue(_:for:) writes to the CCCD for you. The case where you touch descriptors manually is reading a human-readable description of a characteristic (CBUUIDCharacteristicUserDescriptionString) for debugging or display.

func peripheral(_ peripheral: CBPeripheral,
                didDiscoverDescriptorsFor characteristic: CBCharacteristic,
                error: Error?) {
    characteristic.descriptors?.forEach { descriptor in
        // 0x2901 = Characteristic User Description — a human-readable label
        if descriptor.uuid == CBUUID(string: "2901") {
            peripheral.readValue(for: descriptor)
        }
    }
}

func peripheral(_ peripheral: CBPeripheral,
                didUpdateValueFor descriptor: CBDescriptor,
                error: Error?) {
    if let label = descriptor.value as? String {
        print("Characteristic label: \(label)")  // e.g. "Heart Rate Measurement"
    }
}

CBUUID

CoreBluetooth’s wrapper around a 128-bit Bluetooth UUID. The Bluetooth spec allows 16-bit “short-form” UUIDs for standard services and characteristics (e.g. 0x180D for Heart Rate Service) — these are aliases into a 128-bit base UUID. Custom services use full 128-bit UUIDs.

You care because CBUUID(string:) accepts both forms, but comparing a 16-bit UUID with a 128-bit UUID always returns false, even if they refer to the same service. Always use the same form consistently — or compare via the uuidString after calling .init(string:) on both.

// Short form (standard services) — fine for GATT-defined UUIDs
let batteryService = CBUUID(string: "180F")

// Full 128-bit form (your custom service)
let sensorService = CBUUID(string: "A1B2C3D4-E5F6-7890-ABCD-EF1234567890")

// Gotcha: these look related but are NOT equal
let short    = CBUUID(string: "180F")
let expanded = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
print(short == expanded)  // false — always use one form

Discover

The explicit async step where you ask a connected peripheral to tell you what services or characteristics it has. Nothing in GATT is auto-populated — you must call discoverServices, wait for the delegate callback, then call discoverCharacteristics, wait again, before you can do anything with the data. The word reflects that you’re actively querying the peripheral’s attribute table.

You care because forgetting this step (or trying to access peripheral.services before the discovery callback fires) gives you nil — with no error and no warning. The full discovery chain is: connect → discoverServices → discoverCharacteristics → (optionally) discoverDescriptors.

// Step 1 — after connecting
peripheral.discoverServices([targetServiceUUID])

// Step 2 — in didDiscoverServices
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    guard let service = peripheral.services?.first(where: { $0.uuid == targetServiceUUID })
    else { return }
    peripheral.discoverCharacteristics([targetCharUUID], for: service)
}

// Step 3 — in didDiscoverCharacteristics: now you can read/write/notify
func peripheral(_ peripheral: CBPeripheral,
                didDiscoverCharacteristicsFor service: CBService,
                error: Error?) {
    guard let char = service.characteristics?.first(where: { $0.uuid == targetCharUUID })
    else { return }
    peripheral.readValue(for: char)
}

Notify vs Indicate

Two ways a peripheral can push value updates to you without you polling. Notify is fire-and-forget — the peripheral sends the new value and does not wait for acknowledgment. Indicate is the acknowledged version — the peripheral waits for your device to confirm receipt before sending another.

You care because the choice affects throughput and reliability. For sensor data streaming (heart rate, accelerometer), notify is used because speed matters more than guaranteed delivery. For critical state changes (battery critically low, door unlocked), indicate ensures nothing is dropped. The API call to enable both is identical — CoreBluetooth handles the acknowledgment for indicate automatically.

// Enabling notify or indicate — same call, the peripheral's properties bitmask
// tells you which one(s) it supports
peripheral.setNotifyValue(true, for: notifyCharacteristic)

// Updates arrive here for both notify and indicate
func peripheral(_ peripheral: CBPeripheral,
                didUpdateValueFor characteristic: CBCharacteristic,
                error: Error?) {
    guard error == nil, let data = characteristic.value else { return }
    // process incoming data
    let reading = SensorReading(data: data)
    delegate?.didReceiveReading(reading)
}

Write With Response vs Write Without Response

Two flavors of writing a value to a characteristic. Write with response (CBCharacteristicWriteType.withResponse) sends the data and waits for the peripheral to acknowledge receipt — you get a delegate callback either confirming success or reporting an error. Write without response (.withoutResponse) is fire-and-forget — faster and lower overhead, but if the packet is dropped, you won’t know.

You care because using .withResponse on a characteristic that only supports .withoutResponse is a silent no-op on older CoreBluetooth versions and a hard error on newer ones. Always check characteristic.properties first. For high-frequency command streams (firmware update chunks, motor control), .withoutResponse is usually required because .withResponse round-trips are too slow.

let command = Data([0x01, 0x00, 0xFF])

if characteristic.properties.contains(.write) {
    // Acknowledged — delegate fires didWriteValueFor on success or error
    peripheral.writeValue(command, for: characteristic, type: .withResponse)

} else if characteristic.properties.contains(.writeWithoutResponse) {
    // Unacknowledged — check canSendWriteWithoutResponse before flooding
    guard peripheral.canSendWriteWithoutResponse else { return }
    peripheral.writeValue(command, for: characteristic, type: .withoutResponse)
}

// Only called for .withResponse writes
func peripheral(_ peripheral: CBPeripheral,
                didWriteValueFor characteristic: CBCharacteristic,
                error: Error?) {
    if let error { print("Write failed: \(error)") }
    else { print("Write acknowledged") }
}

RSSI

Received Signal Strength Indicator. A negative number in dBm representing how strong the radio signal from a peripheral is at the moment of measurement. A value of -50 is a strong, close signal. -90 is weak and distant. The scale is logarithmic — -60 is not “twice as close” as -120.

You care because RSSI is your only approximation of distance, and it is a rough one. Walls, the human body, interference from Wi-Fi and other devices all affect it unpredictably. Use it for coarse proximity (“is the device probably in this room?”), not for precise ranging. You can read RSSI continuously by calling peripheral.readRSSI() on a timer, or you get it once for free in the didDiscover callback during scanning.

// RSSI during scan — free, no connection needed
func centralManager(_ central: CBCentralManager,
                    didDiscover peripheral: CBPeripheral,
                    advertisementData: [String: Any],
                    rssi RSSI: NSNumber) {
    let strength = RSSI.intValue
    let proximity: String
    switch strength {
    case -60...:    proximity = "immediate"   // < 0.5m, roughly
    case -75..<(-60): proximity = "near"
    case -90..<(-75): proximity = "far"
    default:        proximity = "out of range"
    }
    print("\(peripheral.name ?? "?"): \(strength) dBm — \(proximity)")
}

// RSSI while connected — call readRSSI(), result arrives in delegate
peripheral.readRSSI()

func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
    print("Live signal: \(RSSI.intValue) dBm")
}

MTU

Maximum Transmission Unit. The largest number of bytes that can be sent in a single BLE packet. The spec floor is 23 bytes; with negotiated MTU extension it can reach up to 517 bytes. The actual negotiated MTU is exposed on CBPeripheral as maximumWriteValueLength(for:).

You care because sending a value larger than the MTU silently truncates it (for .withoutResponse) or causes an error (for .withResponse). When transferring large payloads — firmware images, long strings, files — you must chunk the data yourself.

func sendLargePayload(_ data: Data, via peripheral: CBPeripheral,
                      to characteristic: CBCharacteristic) {
    let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse)
    var offset = 0

    while offset < data.count {
        let end = min(offset + mtu, data.count)
        let chunk = data[offset..<end]
        guard peripheral.canSendWriteWithoutResponse else { break }
        peripheral.writeValue(Data(chunk), for: characteristic, type: .withoutResponse)
        offset = end
    }
}

CBManagerState

The state machine that CBCentralManager (and CBPeripheralManager) go through before you can use BLE. The most important states are .poweredOn (ready), .poweredOff (Bluetooth is off in Settings), .unauthorized (user denied the permission prompt), and .unsupported (the device has no BLE hardware).

You care because calling scanForPeripherals before .poweredOn does nothing — no error, no warning, just silence. The correct pattern is to always gate your BLE work inside centralManagerDidUpdateState, not in viewDidLoad or init.

func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch central.state {
    case .poweredOn:
        central.scanForPeripherals(withServices: [targetService])
    case .poweredOff:
        showAlert("Turn on Bluetooth in Settings.")
    case .unauthorized:
        showAlert("Bluetooth permission was denied. Enable it in Settings > Privacy.")
    case .unsupported:
        showAlert("This device does not support Bluetooth Low Energy.")
    case .resetting:
        break  // system is restarting BLE stack — wait for next state update
    case .unknown:
        break  // transitional state on startup — wait
    @unknown default:
        break
    }
}

Background Execution Modes

By default, CoreBluetooth work stops the moment your app is suspended. Two Info.plist keys unlock background operation: bluetooth-central lets your app continue scanning and receiving notifications while backgrounded; bluetooth-peripheral lets your app advertise and respond to reads/writes while backgrounded.

You care because the behavior difference is dramatic and not obvious from testing in the foreground. Without the key, a user switching apps drops your BLE connection silently. There is also a power trade-off: background scanning drains battery faster, so Apple restricts apps that abuse it during App Review.

<!-- Info.plist -->
<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
    <!-- add bluetooth-peripheral only if your app also acts as a peripheral -->
</array>
// With bluetooth-central enabled, scanning continues in the background.
// Apple may coalesce duplicate advertisement packets to save battery —
// CBCentralManagerScanOptionAllowDuplicatesKey only works in the foreground.
central.scanForPeripherals(
    withServices: [targetService],
    options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)

State Restoration

When iOS terminates your app to reclaim memory and the user later re-opens it, CoreBluetooth can hand back the exact list of peripherals you were connected to, scans that were active, and services you were subscribed to. This is called state restoration. Without it, you have to re-scan and re-connect from scratch every cold launch.

You care if your app needs a persistent BLE connection (a wearable, a medical device, a lock). Enabling it requires passing a restoration identifier at init time and implementing centralManager(_:willRestoreState:) to reconnect to the handed-back peripherals.

// Pass a restoration identifier at init — this opts in to state restoration
central = CBCentralManager(
    delegate: self,
    queue: nil,
    options: [CBCentralManagerOptionRestoreIdentifierKey: "com.myapp.central"]
)

// Called on relaunch before didUpdateState — peripherals and scans are handed back
func centralManager(_ central: CBCentralManager,
                    willRestoreState dict: [String: Any]) {
    let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
    for peripheral in restored {
        peripheral.delegate = self
        // Peripheral may already be connected — check state before calling connect()
        if peripheral.state != .connected {
            central.connect(peripheral)
        }
    }
}

CBPeripheralManager

The other side of the coin — this is the class that lets your iPhone act as a peripheral. It handles advertising, responding to reads and writes from a connected central, and sending notifications. Most apps never use this; it’s for building accessories, inter-device communication, or sharing data peer-to-peer via BLE.

You care about the distinction because the API is symmetrical but inverted: instead of discovering services, you add services; instead of reading characteristics, you respond to read requests; instead of calling setNotifyValue, you call updateValue to push data to subscribers.

class BLEPeripheral: NSObject, CBPeripheralManagerDelegate {
    private var manager: CBPeripheralManager!
    private var characteristic: CBMutableCharacteristic!

    func start() {
        manager = CBPeripheralManager(delegate: self, queue: nil)
    }

    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        guard peripheral.state == .poweredOn else { return }

        characteristic = CBMutableCharacteristic(
            type: CBUUID(string: "A1B2C3D4-0001-0001-0001-000000000001"),
            properties: [.read, .notify],
            value: nil,
            permissions: [.readable]
        )

        let service = CBMutableService(
            type: CBUUID(string: "A1B2C3D4-0001-0001-0001-000000000000"),
            primary: true
        )
        service.characteristics = [characteristic]
        manager.add(service)
    }

    func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
        manager.startAdvertising([
            CBAdvertisementDataServiceUUIDsKey: [service.uuid],
            CBAdvertisementDataLocalNameKey: "MyDevice"
        ])
    }

    func push(_ payload: Data) {
        manager.updateValue(payload, for: characteristic, onSubscribedCentrals: nil)
    }
}

L2CAP Channel

A raw socket-like channel over BLE, bypassing GATT entirely. You get a stream-oriented pipe between central and peripheral with no packet-size or characteristic overhead. Introduced in iOS 11. The name stands for Logical Link Control and Adaptation Protocol — the layer of the Bluetooth stack it sits on.

You care when you need to transfer large amounts of data quickly: firmware updates, file transfers, audio streams. GATT has overhead per characteristic write; L2CAP lets you stream bytes directly at close to the theoretical BLE throughput ceiling (~100–300 KB/s depending on conditions).

// Peripheral side: open a PSM (Protocol/Service Multiplexer — think "port number")
var l2capChannel: CBL2CAPChannel?

manager.publishL2CAPChannel(withEncryption: true)

func peripheralManager(_ peripheral: CBPeripheralManager,
                       didPublishL2CAPChannel PSM: CBL2CAPPSM,
                       error: Error?) {
    // Advertise the PSM value so the central knows which channel to open
    print("PSM: \(PSM)")  // write this into a characteristic so the central can read it
}

func peripheralManager(_ peripheral: CBPeripheralManager,
                       didOpen channel: CBL2CAPChannel?,
                       error: Error?) {
    l2capChannel = channel
    channel?.inputStream.open()
    channel?.outputStream.open()
}

// Central side: open the channel using the PSM read from the characteristic
peripheral.openL2CAPChannel(psm)

func peripheral(_ peripheral: CBPeripheral,
                didOpen channel: CBL2CAPChannel?,
                error: Error?) {
    // channel.inputStream / channel.outputStream — use like any Stream
}