Scanning for Peripherals on iOS Core Bluetooth

a woman sitting at a table with a laptop

It’s a common story in Core Bluetooth development: you call scanForPeripherals, and…. nothing. Or the device shows up sometimes but not others. Or it works perfectly in the foreground but disappears when your app backgrounds. Or it behaves completely differently than the same peripheral does on Android.

Scanning for BLE peripherals on iOS isn’t just about calling a function and waiting for results. It’s a cooperative process between your app, the iOS Bluetooth stack, and the peripheral’s advertising strategy—and Apple controls more of that process than many developers initially realize.

This article is for developers who already know how to call scanForPeripherals(withServices:options:) but need their scanning flows to be reliable in production. We’ll explain how scanning actually works on iOS, what factors affect whether devices show up in your results, and how to design around platform behavior you can’t control. Whether you’re troubleshooting flaky scans or coordinating with your firmware team on advertising strategy, understanding these fundamentals will help you build scanning flows that hold up outside the lab.


Understanding the Scanning Lifecycle

Scanning isn’t a synchronous loop you can start whenever you want. It’s an asynchronous, OS-managed process with a specific lifecycle. The most common mistake developers make is trying to start scanning before iOS is ready.

Wait for .poweredOn

Before you can scan for anything, you need to initialize a CBCentralManager and wait for it to report that Bluetooth is ready. When you create your central manager, you’ll assign a delegate to receive state updates:

class BluetoothViewController: UIViewController {
    private var centralManager: CBCentralManager!

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

The delegate must implement centralManagerDidUpdateState(), and you should only begin scanning once the state is .poweredOn:

extension BluetoothViewController: CBCentralManagerDelegate {
 
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        	case .poweredOn:
            	startScan()
        	case .poweredOff:
                // Alert user to turn on Bluetooth
        	case .resetting:
                // Wait for next state update and consider logging interruption of Bluetooth service
        	case .unauthorized:
                // Alert user to enable Bluetooth permission in app Settings
        	case .unsupported:
            	// Alert user their device does not support Bluetooth and app will not work as expected
        	case .unknown:
           	// Wait for next state update
        }
    }
}

If you try to call scanForPeripherals before receiving .poweredOn, you’ll see warnings in the Xcode debugger or your scan will silently fail to start. iOS needs time to initialize the Bluetooth subsystem and confirm your app has the necessary permissions. Don’t skip this step.

How Scan Results Are Delivered

Once scanning begins, iOS doesn’t hand you a list of devices. Instead, it calls your delegate method centralManager(_:didDiscover:advertisementData:rssi:) every time it detects an advertisement packet from a peripheral—assuming that peripheral passes your filters and iOS decides to report it.

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

By default iOS de-duplicates discoveries per device, but may deliver additional callbacks if the payload changes. Don’t rely on this behavior. This is Apple’s way of conserving power and reducing noise. If you need updated RSSI values throughout the scan (for example, to estimate proximity), you’ll need to enable duplicate filtering by passing CBCentralManagerScanOptionAllowDuplicatesKey: true in your scan options but be aware this significantly increases power consumption.

The key point: scanning is asynchronous and non-deterministic. You can’t predict exactly when iOS will report a device, how many times, or in what order. Your scanning logic needs to accommodate this uncertainty.


What Controls Peripheral Visibility During Scanning

Even if your peripheral is advertising and your app is scanning, that doesn’t guarantee you’ll see it. Several factors (some within your control, some not) determine whether a device appears in your scan results.

Service UUID Filters

When you call scanForPeripherals(withServices:options:), the first parameter is an optional array of service UUIDs. If you provide this array, iOS will only return peripherals that advertise at least one of those services.

This sounds straightforward, but there’s a catch: peripherals don’t always advertise all of their services. In fact, most peripherals only advertise a subset—often just one or two custom service UUIDs that act as identifiers. If your filter includes a service the peripheral contains but doesn’t advertise, iOS won’t report the device.

Best practice: Start with nil for the services parameter (no filter) to confirm your peripheral is visible at all. Once you’ve verified it shows up, you can narrow your filter to improve efficiency and reduce noise from irrelevant devices. If you’re not seeing a device you expect, the first thing to check is whether it’s actually advertising the service UUID you’re filtering for.

Advertisement Parameters

How a peripheral advertises has a huge impact on discoverability. iOS does not continuously monitor for BLE advertisements; instead, it uses internal power-optimized scanning intervals that are not publicly documented. If your peripheral is advertising too infrequently, iOS might miss it entirely during those sampling windows.

The advertisement interval is set on the peripheral side (firmware), but as a mobile developer, you need to be aware of how it affects your scan results. An interval around 100 ms is a solid engineering baseline for reliable discovery during active scanning. Longer intervals (500ms or more) can lead to delays or missed detections, especially in background mode when iOS scans less frequently.

Another factor: the advertisement’s connectability affects whether iOS reports it, especially in the background. In the foreground, iOS will usually report both connectable and non-connectable advertisements (including beacons), as long as you are not filtering by service UUID. However, in background scanning, iOS only reports connectable advertisements whose service UUIDs match the filters you specify. Non-connectable advertisements are suppressed entirely while backgrounded.

To check connectability, look for the CBAdvertisementDataIsConnectable key in the advertisement dictionary.

iOS’s advertisement detection behavior is based on internal power-optimized heuristics that Apple does not document publicly. This means the exact timing, frequency, and aggressiveness of scanning can vary between device models, OS versions, and even battery conditions. You should assume scanning is opportunistic rather than continuous.

Duplicate Filtering and RSSI Updates

As mentioned earlier, iOS filters duplicate advertisements by default. This means you get one callback per device, even if the device continues advertising for the next ten minutes.

If you need to track signal strength over time (for example, to implement proximity-based features or to sort devices by distance), you’ll need to enable duplicates:

// In main class
var discoveredPeripherals = [CBPeripheral]()
func startScan() {
    centralManager.scanForPeripherals(withServices: nil, options: nil)
}
...
...
...
// In CBCentralManagerDelegate class/extension
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    self.discoveredPeripherals.append(peripheral)
}

With this option enabled, you’ll receive a callback for every advertisement packet iOS detects. This gives you fresh RSSI values but comes at the cost of significantly higher power consumption and more delegate callbacks to process. Use it judiciously.

Note: Even with duplicate filtering enabled (the default), iOS may still deliver additional didDiscover callbacks if the peripheral’s advertisement payload changes—for example, if its manufacturer data or service data updates. This behavior is undocumented and not guaranteed, so you should not rely on it, but it does occur in practice.

Foreground vs. Background Scanning

This is where many developers hit a wall. Scanning in the foreground is relatively permissive. iOS allows your app to scan frequently and report results in near real-time. But once your app moves to the background, the rules change dramatically.

In background mode:

  • Scan intervals are much longer (on the order of seconds, not milliseconds)
  • Only peripherals advertising specific service UUIDs you’ve filtered for will be reported
  • Duplicate filtering is enforced even if you requested duplicates in the foreground

This means two things:

  1. You must filter by service UUID to receive any results in the background. Scanning with nil services won’t work.
  2. Don’t expect real-time discovery. Background scans are designed for eventual detection, not immediate responsiveness.

Important requirement: iOS will only return background scan results for service UUIDs that your app has declared in its Info.plist under the “bluetooth-central” background mode entry. If your scan filter includes a service UUID not listed in your Info.plist, iOS treats it as if you’re scanning for nothing, and you will receive zero background discoveries.

This requirement does not apply in the foreground but is strictly enforced in the background.


Control vs. Platform Constraints

One of the most important mindset shifts for iOS BLE development is understanding where your control ends and Apple’s begins.

What You Can Control

You have full control over:

  • When to start and stop scanning: Call scanForPeripherals when you need discovery, and call stopScan() when you’re done to conserve battery.
  • Service UUID filters: Choose which services to filter for (or not).
  • Scan options: Enable duplicate filtering or other options as needed.
  • Scan lifecycle management: Structure your scanning logic to handle state changes, backgrounding, and reconnection flows.

What iOS Controls

iOS manages everything else, and your app has to work within these constraints:

  • Scheduling: When and how often it actually samples for advertisements.
  • Throttling: Power-saving policies that limit scan frequency, especially in the background.
  • Permission prompts: When and how the Bluetooth permission dialog appears (you can’t trigger or customize it).
  • Caching and optimization: iOS may cache peripheral information or prioritize certain devices based on system-level heuristics you can’t influence.

This isn’t a limitation to work around, it’s the reality of developing on a platform that prioritizes battery life and user privacy. The best scanning flows are designed with these constraints in mind, not against them.


Best Practices for Reliable Scanning

Here’s how to build scanning logic that accounts for these constraints and works consistently in production:

Always Wait for .poweredOn

Never assume Bluetooth is ready. Always wait for centralManagerDidUpdateState() to report .poweredOn before calling scanForPeripherals. This avoids silent failures and permission issues.

Start Broad, Then Narrow

If you’re troubleshooting scan issues, start with no service filter (nil) to confirm the peripheral is visible at all. Once you’ve verified it shows up, add your service UUID filter. This helps isolate whether the problem is with your filter, the peripheral’s advertising, or something else entirely.

Note: On certain older iOS versions (primarily iOS 12–15), scanning with no service filter in very busy BLE environments may reduce discoverability because iOS attempts to self-optimize scan results. This is not typical in most environments but it’s worth being aware of if you see inconsistent results on older devices.

Coordinate with Firmware

Work with your firmware team to ensure the peripheral’s advertisement interval and service UUIDs align with your mobile scanning strategy. An interval of 100ms is a good baseline for reliable discovery. Make sure the service UUIDs you’re filtering for are actually being advertised, not just present in the GATT table.

Design for Background Constraints

If your app needs to discover devices while backgrounded, accept that it will be slower and less responsive than foreground scanning. Filter by service UUID (it’s required), and set user expectations accordingly. Don’t promise instant discovery if your app spends most of its time in the background.

Additionally, background scans do not automatically resume after certain system-level events such as Bluetooth resets (.resetting state), Airplane Mode toggles, or rare system radio interruptions. Your app should listen for Core Bluetooth state changes and restart scanning once the central returns to .poweredOn.

Use Tools to Isolate Issues

Before blaming your code, confirm the peripheral is advertising correctly using a tool like LightBlue or nRF Connect. If these apps can’t see your device either, the issue is on the peripheral side. This saves hours of debugging in the wrong layer.

Test in Realistic Conditions

Scanning in Xcode with a device on your desk is not the same as scanning in production. Test with:

  • Your app backgrounded
  • Multiple iPhone models (older devices may behave differently)
  • Several peripherals advertising simultaneously
  • Peripherals at varying distances and RSSI levels

Real-world conditions expose edge cases that controlled testing misses.

Scanning behavior can also vary slightly between different iPhone models and Bluetooth chipsets. Newer phones generally scan more aggressively and recover faster from radio congestion. For thorough testing, always include at least one older device in your test matrix.


Designing for Predictability, Not Perfection

Scanning for BLE peripherals on iOS is a partnership between your app and the operating system. You control the “what” and “when,” but Apple controls the “how” and “how often.” Understanding this division is the key to building scanning flows that are robust, predictable, and don’t fall apart under real-world conditions.

The scanning lifecycle is straightforward once you respect its asynchronous nature: initialize your central manager, wait for .poweredOn, start scanning with appropriate filters, and handle results as iOS delivers them. But reliability comes from understanding the factors that affect visibility (advertisement intervals, service filters, background constraints) and designing your scanning logic to work with the platform’s limitations, not against them.

If you’re still running into issues after applying these practices, the problem might not be with scanning at all. Check out our article on Why Your BLE Scan Returns No Results for deeper troubleshooting guidance, or explore our iOS Core Bluetooth Ultimate Guide for a complete reference on working with BLE on iOS.

Scanning isn’t perfect, but it doesn’t need to be. It just needs to be predictable enough that you can build reliable product experiences on top of it. And that starts with understanding how it actually works.

Share:

Punch Through
Punch Through
We’re a team of engineers who obsess over making connected things actually work — reliably, securely, and without the handwaving. From BLE to backend, we build the software and systems behind connected medical devices and custom connected products that can’t afford to fail.

Subscribe to stay up-to-date with our latest articles and resources.