CBPeripheral is a class of the Core Bluetooth framework that represents a remote BLE device on iOS. Each CBPeripheral object encapsulates the UUID, name, RSSI, and GATT service hierarchy of a connected BLE device. The developer interacts with the peripheral exclusively through CBPeripheral: service discovery (discoverServices:), characteristic reading (readValueForCharacteristic:), data writing (writeValue:forCharacteristic:type:), and notification subscription (setNotifyValue:forCharacteristic:). According to Apple Developer, 2026, CBPeripheral is the central object for all BLE peripheral operations, returned by CBCentralManager upon device discovery or connection.
Key Takeaways
CBPeripheral is an object representing a remote BLE device in an iOS application. Unlike CBCentralManager, which manages the local iPhone Bluetooth adapter, CBPeripheral models an external peripheral device: a sensor, fitness tracker, beacon, or medical instrument. Each CBPeripheral instance contains a unique identifier (UUID) that persists between connection sessions — Apple links the UUID to a specific device via system Bonding.
CBPeripheral is not created directly via init. The Core Bluetooth framework returns a CBPeripheral object in two scenarios: when a device is discovered via scanForPeripheralsWithServices: (delegate didDiscoverPeripheral) and when connecting to a previously known device via retrievePeripheralsWithIdentifiers:. After obtaining the object, the developer calls connectPeripheral: on CBCentralManager, after which CBPeripheral becomes available for GATT operations.
CBPeripheral Lifecycle includes six states: disconnected (initial), connecting (after calling connect), connected (after didConnectPeripheral), discovering (during discoverServices call), discovered (after receiving services), and disconnecting (after cancelPeripheralConnection). Each state is tracked through the CBPeripheralDelegate protocol — a must-have for any BLE application on iOS.
CBPeripheral stores a hierarchical GATT structure consisting of three levels. The root level is an array of CBService (services), each service contains an array of CBCharacteristic (characteristics), each characteristic contains an array of CBDescriptor (descriptors). This model fully conforms to the Bluetooth GATT specification: a service is a device function (e.g., “Heart Rate Service”), a characteristic is a specific value (pulse 72 bpm), a descriptor is characteristic metadata (measurement units, notification configuration).
| Level | Core Bluetooth Class | Description |
|---|---|---|
| Service | CBService | Logical group of related characteristics, identified by UUID (16-bit, 32-bit, or 128-bit) |
| Characteristic | CBCharacteristic | Specific data value, supports reading, writing, and notifications |
| Descriptor | CBDescriptor | Characteristic metadata: client configuration CCCD, User Description, Presentation Format |
Standard BLE services are registered by Bluetooth SIG: Heart Rate Service (UUID 180D), Battery Service (180F), Device Information (180A), Blood Pressure (1810). Custom services use 128-bit UUIDs (e.g., E20A39F4-73F5-4BC4-A12F-17D1AD07A961). iOS automatically recognizes standard UUIDs and displays human-readable names; custom UUIDs appear in hex format.
After connection, the CBPeripheral hierarchy is empty — services and characteristics are not loaded. The developer must call discoverServices: to retrieve services and then for each service call discoverCharacteristics:forService:. If the service contains included services, additionally call discoverIncludedServices:forService:. Only after the discovery hierarchy is complete does CBPeripheral become populated and available for reading and writing.
Discovery of the CBPeripheral GATT structure is a mandatory step before any read or write operations. The discoverServices: method initiates an asynchronous search for all device services. If nil is passed, all services are discovered; if an array of CBUUID is passed — only services with the specified UUIDs (time optimization). The result arrives in the delegate peripheral:didDiscoverServices: — the CBPeripheral object populates its services property with an array of CBService.
After receiving services, for each CBService you must call discoverCharacteristics:forService:. Similarly, nil — all characteristics, array of CBUUID — only specified ones. The result: peripheral:didDiscoverCharacteristicsForService:error:. At this stage, CBCharacteristic receives properties (properties: .read, .write, .notify, .indicate) that define allowed operations.
import CoreBluetooth
extension BLEViewController: CBPeripheralDelegate {
// 1. Service discovery
func peripheral(_ peripheral: CBPeripheral,
didDiscoverServices error: Error?) {
guard let services = peripheral.services else { return }
for service in services {
// Request characteristics for each service
peripheral.discoverCharacteristics(nil, for: service)
}
}
// 2. Characteristic discovery
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
if characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic)
}
}
}
// 3. Read value
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard let data = characteristic.value,
let value = String(data: data, encoding: .utf8)
else { return }
print("Characteristic value: \(value)")
}
}
In the example, CBPeripheralDelegate implements three required discovery methods. didDiscoverServices iterates through all found services and requests characteristics. didDiscoverCharacteristicsForService checks each characteristic’s properties: for .read it calls readValue, for .notify it calls setNotifyValue(true). The didUpdateValueForCharacteristic method receives the actual value in Data format.
Reading values of CBCharacteristic is performed using the readValueForCharacteristic: method. The result arrives asynchronously in peripheral:didUpdateValueForCharacteristic:error:. Note: the device may have a cached value (characteristic.value is available immediately after discovery), but to obtain the current data, calling readValue is mandatory. iOS may cache values for energy efficiency — readValue refreshes the cache.
Writing values is performed using the writeValue:forCharacteristic:type: method. The type parameter determines the write type: .withResponse (CBCharacteristicWriteWithResponse) — the device confirms the write via didWriteValueForCharacteristic; .withoutResponse (CBCharacteristicWriteWithoutResponse) — write without confirmation, maximum speed but no delivery guarantee. The BLE specification limits MTU (Maximum Transmission Unit): up to 23 bytes for BLE 4.0, up to 251 bytes for BLE 5.0+. For data larger than MTU, application-level fragmentation is required.
// CBPeripheral characteristic read and write
class BLEService {
private let peripheral: CBPeripheral
private let serviceUUID = CBUUID(string: "180D")
private let charUUID = CBUUID(string: "2A37")
init(peripheral: CBPeripheral) {
self.peripheral = peripheral
}
// Read with response
func readHeartRate() {
guard let service = peripheral.services?.first(where: { $0.uuid == serviceUUID }),
let characteristic = service.characteristics?.first(where: { $0.uuid == charUUID })
else { return }
peripheral.readValue(for: characteristic)
}
// Write with response (withResponse)
func writeWithResponse(data: Data) {
guard let characteristic = findCharacteristic() else { return }
peripheral.writeValue(data, for: characteristic,
type: .withResponse)
}
// Write without response (withoutResponse)
// Max throughput, no delivery guarantee
func writeWithoutResponse(data: Data) {
guard let characteristic = findCharacteristic() else { return }
peripheral.writeValue(data, for: characteristic,
type: .withoutResponse)
}
private func findCharacteristic() -> CBCharacteristic? {
return peripheral.services?
.flatMap { $0.characteristics ?? [] }
.first(where: { $0.uuid == charUUID })
}
}
Choosing the write type withResponse or withoutResponse depends on reliability requirements. For commands (turn on light, unlock door) use withResponse — delivery guarantee is critical. For streaming data (pulse, temperature) use withoutResponse — losing one packet is insignificant. A BLE device may support only one write type — check the characteristic.properties.contains(.write) and .writeWithoutResponse property.
Notifications are a BLE mechanism where the peripheral device sends characteristic values to the central device asynchronously, without constant polling from the central side. CBPeripheral enables subscription using the setNotifyValue:forCharacteristic: method. After activating the subscription, iOS automatically writes to the CCCD (Client Characteristic Configuration Descriptor) on the peripheral, and the device starts sending updates each time the value changes.
Unlike indications, notifications do not require acknowledgment from the central device — the packet is sent and forgotten. This provides maximum throughput but packets may be lost. Indications require acknowledgment at the protocol level (L2CAP) — more reliable but slower. CBCharacteristic’s properties property precisely indicates which mode is supported: .notify, .indicate, or both.
When CBPeripheral disconnects (disconnect, out of range), all active subscriptions are automatically reset. Upon reconnection, you must call setNotifyValue:true again for each characteristic. iOS also loses subscriptions when the app leaves the foreground (if background mode is not enabled) — for background operation, the capability “Uses Bluetooth LE accessories” must be enabled in Info.plist.
// CBPeripheral notification subscription management
class NotificationManager: NSObject {
private var peripheral: CBPeripheral?
private var subscribedCharacteristics: Set<CBUUID> = []
// Subscribe to notifications for all .notify characteristics
func subscribeToAllNotifications(peripheral: CBPeripheral) {
self.peripheral = peripheral
guard let services = peripheral.services else { return }
for service in services {
guard let characteristics = service.characteristics else { continue }
for characteristic in characteristics {
if characteristic.properties.contains(.notify)
|| characteristic.properties.contains(.indicate) {
peripheral.setNotifyValue(true, for: characteristic)
subscribedCharacteristics.insert(characteristic.uuid)
}
}
}
}
// Unsubscribe from all notifications
func unsubscribeFromAll() {
guard let peripheral = peripheral else { return }
guard let services = peripheral.services else { return }
for service in services {
guard let characteristics = service.characteristics else { continue }
for characteristic in characteristics {
if subscribedCharacteristics.contains(characteristic.uuid) {
peripheral.setNotifyValue(false, for: characteristic)
}
}
}
subscribedCharacteristics.removeAll()
}
// Notification handler
func peripheral(_ peripheral: CBPeripheral,
didUpdateNotificationStateFor characteristic: CBCharacteristic,
error: Error?) {
if characteristic.isNotifying {
print("Subscription active: \(characteristic.uuid)")
} else {
print("Subscription inactive: \(characteristic.uuid)")
}
}
}
The NotificationManager demonstrates proper handling of CBPeripheral notifications. subscribeToAllNotifications iterates through all services and characteristics, activating .notify and .indicate. subscribedCharacteristics tracks active subscriptions for proper unsubscription. didUpdateNotificationStateForCharacteristic confirms the successful subscription state change via the characteristic.isNotifying property.
Full workflow with CBPeripheral includes: obtaining the object from CBCentralManager, connecting, discovery, reading/writing, notification subscription, and disconnection. The example below implements a BLEConnection class that manages the complete BLE peripheral lifecycle in Swift using the modern async/await API (iOS 15+).
import CoreBluetooth
// Full CBPeripheral management example with async/await
class BLEConnection: NSObject {
private let centralManager: CBCentralManager
private var peripheral: CBPeripheral?
private var continuation: CheckedContinuation<Void, Error>?
override init() {
centralManager = CBCentralManager(delegate: nil, queue: .main)
super.init()
centralManager.delegate = self
}
// 1. Connect to peripheral
func connect(to peripheral: CBPeripheral) async throws {
self.peripheral = peripheral
peripheral.delegate = self
centralManager.connect(peripheral, options: nil)
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
}
}
// 2. Discovery
func discoverServices() async throws {
guard let peripheral = peripheral else {
throw BLEError.notConnected
}
peripheral.discoverServices(nil)
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
}
}
}
// 3. CBCentralManager
extension BLEConnection: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
// Handle Bluetooth device state
}
func centralManager(_ central: CBCentralManager,
didConnect peripheral: CBPeripheral) {
continuation?.resume()
continuation = nil
}
func centralManager(_ central: CBCentralManager,
didFailToConnect peripheral: CBPeripheral,
error: Error?) {
continuation?.resume(throwing: error ?? BLEError.connectionFailed)
continuation = nil
}
}
enum BLEError: Error {
case notConnected
case connectionFailed
case serviceNotFound
case characteristicNotFound
}
The BLEConnection class uses Swift Concurrency (async/await) via CheckedContinuation — a modern pattern for working with delegate-based Core Bluetooth APIs. connect(to:) awaits connection confirmation through didConnectPeripheral, discoverServices() — through didDiscoverServices. This approach eliminates nested delegates and makes BLE code linear and readable. Error handling through BLEError covers all typical BLE connection failure scenarios.
Frequently Asked Questions
CBPeripheral for a previously connected device can be obtained via retrievePeripheralsWithIdentifiers: on CBCentralManager. Pass an array of UUIDs (NSUUID) of previously saved devices — the framework returns an array of CBPeripheral for devices in the system BLE bonding database. This only works for devices that the iPhone has previously paired with. For a new device, scanning is required.
Common causes: the device is out of range (RSSI below threshold), BLE radio is off (CBCentralManager.state != .poweredOn), the CBPeripheralDelegate is not set (peripheral.delegate = self), or discoverServices was called before connection. Check centralManager.state, ensure the delegate is set before calling connect, and use a retry with a 5–10 second timeout.
The cause is using .withResponse on a characteristic that only supports .writeWithoutResponse, or vice versa. Check characteristic.properties before calling. Another possible issue is MTU: if data exceeds 20 bytes (BLE 4.0 MTU), MTU negotiation via negotiateMTU or fragmentation is needed. Use peripheral.maximumWriteValueLength(for: .withResponse) to determine the maximum packet size.
CBPeripheral out of range does not disconnect immediately — iOS transitions it to the .disconnected state after a timeout (typically 20–30 seconds). For monitoring, use readRSSI on CBPeripheral — if unavailable, it will return an error with code CBError.connectionTimeout. Also monitor centralManager:didDisconnectPeripheral:error: for timely detection of connection loss.
Core Bluetooth is not thread-safe — all CBPeripheral calls must be made from the same queue (usually the main queue or a serial queue specified when initializing CBCentralManager). Concurrent calls from different threads lead to race conditions and app crashes. Use DispatchQueue(label: “com.app.ble”) for all BLE operations and DispatchQueue.main.async for UI updates.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also