Why your CoreBluetooth scan finds nothing on a real device
Four reasons a BLE scan that works in the simulator returns an empty list on real hardware, and how to tell them apart.
A scan that returns nothing is the most common first bug in a CoreBluetooth integration. It is almost never a broken peripheral. Four causes account for nearly all of them, and each one leaves a different clue.
1. You scanned before the central was ready
scanForPeripherals is silently ignored unless the central manager's state is .poweredOn. Call it in viewDidLoad, right after constructing the manager, and nothing happens. You get no error and no warning, just an empty result. The manager reports its state asynchronously, so the only safe place to start a scan is the delegate callback.
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn else {
// .unauthorized, .poweredOff and .unsupported are all
// user-visible states, so surface them rather than retrying.
return
}
central.scanForPeripherals(withServices: [serviceUUID])
}
2. You filtered on a service the peripheral doesn't advertise
Passing a service UUID to scanForPeripherals filters against the advertisement packet, not against the services the peripheral exposes once connected. A device can implement your service and never mention it while advertising. The advertising payload is only 31 bytes, and firmware teams tend to spend it on a name and manufacturer data.
To find out which one you have, scan with nil and log whatever arrives. If the peripheral shows up unfiltered and vanishes when you add the filter, the UUID belongs in the scan response. That is a firmware change, not an app one.
3. Location permission on older deployment targets
Bluetooth permission moved to NSBluetoothAlwaysUsageDescription in iOS 13. An app still carrying only the older NSBluetoothPeripheralUsageDescription key builds fine and scans nothing on a modern device, because the permission prompt never appears. Check the state your delegate reports. .unauthorized points to a missing or wrong Info.plist key far more often than to a user who declined.
4. You are scanning in the background
Background scanning has its own rules:
- The service UUID filter becomes mandatory. A nil filter discovers nothing at all.
- CBCentralManagerScanOptionAllowDuplicatesKey is ignored, so you get one callback per peripheral, not a stream.
- The OS matches advertisements against its own filter, so discovery is slower and the timing is out of your hands.
This is why a scan that works fine on a desk fails the moment the tester locks their phone and puts it in a pocket. Test with the screen off before you call a BLE feature finished.
One habit finds all four
Log every state transition of the central manager, including the boring ones.
These bugs stay invisible because CoreBluetooth fails quietly. One log line in centralManagerDidUpdateState, printed on every transition, turns all four into something you spot in seconds, rather than losing an afternoon to suspecting the hardware.