← All posts

CoreBluetooth State Restoration on iOS: The Complete Guide (2026)

The most-Googled CoreBluetooth pain point, with the worst public answers. How state restoration works on iOS 16+, what Apple's docs leave out, and the five failure modes everyone hits.

Shailendra Kumar Ram

The most-Googled CoreBluetooth pain point, with the worst public answers. Here’s how state restoration works on iOS 16+, what Apple’s docs leave out, and the five failure modes everyone hits.

State restoration is the part of CoreBluetooth that makes “background BLE” possible. Without it, your app connects on launch, drops the moment iOS suspends it, and starts from scratch on relaunch: connection history, in-flight discoveries, pending operations, all gone. Apps that ship without it drop scans within 30 seconds of going to background, and most teams find out from one-star App Store reviews about “doesn’t reconnect.”

This post walks through:

  1. What state restoration is (and what it isn’t)
  2. The four-part setup nobody documents in one place
  3. The willRestoreState delegate method: what’s inside the dictionary and what to do with it
  4. Handling background relaunches in your app delegate / SwiftUI App
  5. The five recurring bugs and how to debug them
  6. How to test state restoration (the part Apple really doesn’t help with)

Code samples are vanilla CoreBluetooth; no library needed. At the end I’ll show how Antenna wraps all of this in one line, but everything here you can build yourself from this post.


What state restoration actually does

When iOS suspends your app (you go to background, lock screen, or just leave the app idle for a few seconds), the OS can keep your CBCentralManager alive in a special “restorable” mode. Three things stay alive:

  • Active connections to peripherals (your heart-rate monitor stays connected)
  • In-flight scans for service UUIDs you specified
  • Pending connection attempts (the ones initiated with connect(_:options:))

Later, when one of those connections produces an event (a notification fires, the peripheral disconnects, a pending connect succeeds), iOS relaunches your app in the background specifically to handle it. Your app gets ~10 seconds to handle the event before iOS suspends it again.

This is what makes apps like smart locks, fitness trackers, and heart-rate monitors work: they keep their connection alive across suspends. Without state restoration, every transition to background = your scan dies, your connections drop, and the next time the user opens the app it has to start from zero.

One distinction worth getting straight: state restoration is not the same as Apple’s CBConnectPeripheralOptionEnableAutoConnect flag (which makes iOS keep retrying a connection forever). Auto-connect is a connection policy. State restoration is the infrastructure that lets your app’s CB state survive across app-suspend cycles in the first place. You need state restoration for auto-connect to be useful in background; without it, the auto-connect dies the moment iOS suspends you.


The four-part setup nobody documents in one place

State restoration requires four things to be set up correctly. Apple’s docs cover each one in a different page; if any of the four is missing, the whole thing silently doesn’t work.

1. bluetooth-central in UIBackgroundModes

In your Info.plist:

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
</array>

In Xcode 14+, this is also a checkbox under Signing & Capabilities → Background Modes. Careful here: “Acts as a Bluetooth LE accessory” is the peripheral role. The one you want is “Uses Bluetooth LE accessories”.

If you’re the central (your app scans/connects to other devices), you want bluetooth-central. If you’re the peripheral (your iPhone advertises a service that other devices connect to), you want bluetooth-peripheral. These are different background modes, and confusingly named.

2. NSBluetoothAlwaysUsageDescription

In Info.plist:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to connect to your fitness tracker.</string>

Your app crashes on first scan without this, regardless of state restoration. The crash is silent in console; you just get terminated with no useful log message. This bites everyone exactly once.

3. CBCentralManagerOptionRestoreIdentifierKey at manager init

This is the part most people miss. The CBCentralManager must be created with a restore identifier:

let central = CBCentralManager(
    delegate: self,
    queue: nil,
    options: [
        CBCentralManagerOptionRestoreIdentifierKey: "com.acme.fitness.central"
    ]
)

The identifier is an arbitrary stable string. iOS uses it to associate the restored state with the right manager when your app relaunches. Two rules:

  • It must be stable across app launches. Don’t use UUID() or any random value; pick a string and hard-code it.
  • It must be unique within your app. If you have multiple central managers (most apps don’t), each needs its own identifier.

The timing matters: this manager must be created before iOS calls willRestoreState. If you create it lazily on first user action, iOS will have already given up by the time you get around to it. Build the manager in your App.init or application(_:didFinishLaunchingWithOptions:), not in a view controller or a service constructor called from a tab tap.

4. centralManager(_:willRestoreState:) delegate method

When iOS relaunches your app in background and there’s CB state to hand back, this method fires before centralManagerDidUpdateState:

func centralManager(
    _ central: CBCentralManager,
    willRestoreState dict: [String: Any]
) {
    let peripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
    let services = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
    let scanOptions = dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]
    
    for peripheral in peripherals {
        peripheral.delegate = self  // ← critical, see below
        // The peripheral is in its previous state: connected, connecting, etc.
        // Resume whatever you were doing.
    }
}

The dictionary keys you actually need:

KeyTypeWhat it is
CBCentralManagerRestoredStatePeripheralsKey[CBPeripheral]Peripherals that were connected, connecting, or being awaited
CBCentralManagerRestoredStateScanServicesKey[CBUUID]Service UUIDs you were scanning for
CBCentralManagerRestoredStateScanOptionsKey[String: Any]?The options dict from your last scanForPeripherals(withServices:options:) call

The gotcha that costs people the most time: the CBPeripheral objects you get back have their delegate property set to nil. If you forget to re-assign peripheral.delegate = self, every subsequent characteristic update from that peripheral will fire into the void with no warning. This is the single most common state-restoration bug, and it manifests as “the peripheral connects but I never receive notifications.”


The app-delegate dance

When iOS relaunches your app due to a BLE event, the launch happens silently in the background. Your application(_:didFinishLaunchingWithOptions:) fires with a specific key in launchOptions:

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    if let identifiers = launchOptions?[.bluetoothCentrals] as? [String] {
        // We're being woken up specifically for one of these central managers.
        // Build the CBCentralManager *immediately* with the matching restoreIdentifier.
        for id in identifiers {
            print("Resuming central:", id)
        }
    }

    // IMPORTANT: build your CBCentralManager here, synchronously,
    // before this method returns. Lazy init is too late.
    centralManager = CBCentralManager(
        delegate: self,
        queue: nil,
        options: [CBCentralManagerOptionRestoreIdentifierKey: "com.acme.fitness.central"]
    )

    return true
}

For SwiftUI App lifecycle (no AppDelegate), use @UIApplicationDelegateAdaptor to bridge in an AppDelegate class that handles this, OR set up the manager in your App.init():

@main
struct FitnessApp: App {
    @State private var bleManager = CentralManager()  // creates CBCentralManager in init
    var body: some Scene { /* … */ }
}

The launch-options key (UIApplication.LaunchOptionsKey.bluetoothCentrals) has the underlying string value "UIApplicationLaunchOptionsBluetoothCentralsKey". If your kit doesn’t import UIKit (we have a kit), you can match against that string directly.


The order of delegate callbacks during relaunch

This is the part nobody documents and the part where most bugs hide:

1. application(_:didFinishLaunchingWithOptions:)        ← launchOptions has bluetoothCentrals key
2. CBCentralManager.init(... restoreIdentifier ...)     ← you create it here
3. centralManager(_:willRestoreState:)                  ← iOS hands back peripherals + scan info
4. centralManagerDidUpdateState                         ← state usually .poweredOn at this point
5. Your delegate methods fire as events accumulate:
   - centralManager(_:didDiscover:advertisementData:rssi:) for any new scan results
   - peripheral(_:didUpdateValueFor:error:) for buffered notifications
   - centralManager(_:didDisconnectPeripheral:error:) if a connection dropped while you were asleep
6. iOS gives you ~10 seconds to handle, then suspends you again

What this means in practice: don’t ignore willRestoreState. If you skip it, your code treats every peripheral as freshly discovered when they’re holdovers from before. You’ll re-issue discoverServices, re-subscribe to notifications, and create duplicate handlers, all of which iOS may silently throttle.

Re-assign peripheral.delegate = self for every peripheral in the dictionary. Re-establish any in-memory state your app needs (cached service info, pending operations) from your own persistence; iOS only hands back the CB objects, not your app’s interpretation of them.


The five recurring bugs (and how to spot them)

Bug 1: “Background scans return nothing”

Cause: scanning in background requires a service-UUID filter. Apple silently drops unfiltered scans when the app is backgrounded.

// Works in foreground, silently drops in background:
central.scanForPeripherals(withServices: nil, options: nil)

// Survives backgrounding:
central.scanForPeripherals(withServices: [CBUUID(string: "180D")], options: nil)

Symptom: scan works perfectly in dev, breaks in TestFlight when phone is locked.

Bug 2: “Restored peripheral connects but no notifications arrive”

Cause: you forgot peripheral.delegate = self in willRestoreState.

The CBPeripheral object loses its delegate when serialized/restored. Every CBPeripheralDelegate callback (including didUpdateValueFor) requires a non-nil delegate.

func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
    let peripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
    for peripheral in peripherals {
        peripheral.delegate = self  // ← THIS LINE
    }
}

Bug 3: “App is woken up but immediately suspended again”

Cause: you took too long to build the CBCentralManager. iOS gives you a very short window (~5 seconds) to set up the manager with the restore identifier. If you defer it (to a tab tap, a view controller viewDidLoad, a network init), iOS suspends you before that code runs.

Fix: build the central manager synchronously in application(_:didFinishLaunchingWithOptions:) (or your SwiftUI App.init).

Bug 4: “Restored state dictionary is empty”

Cause: usually one of:

  • You changed the restore identifier between app versions (the previous state is orphaned and iOS drops it)
  • Your previous run never actually scanned or connected with restoration enabled
  • iOS purged the state due to memory pressure or a system reboot

Debug: log every willRestoreState call with the dictionary contents, including absence. If you never see the method called at all, the restore identifier isn’t matching, or background mode isn’t configured.

Bug 5: “Works for me, doesn’t work for users”

Cause: background BLE is throttled differently by iOS based on:

  • App refresh setting (Settings → General → Background App Refresh)
  • Low Power Mode
  • Device-wide battery saver
  • Whether the device has been recently rebooted (counter resets daily-ish)

Symptom: works on your unplugged dev iPhone for an hour, breaks after 30 seconds on a customer’s phone in low-power mode.

Fix: there’s no fix. You can’t override iOS’s throttling policy. What you can do:

  • Test with Background App Refresh disabled (worst case)
  • Test in Low Power Mode
  • Use a service-UUID filter (above) so iOS deems your scans worth waking the app for
  • Implement clean reconnection on relaunch so transient drops don’t ruin the user experience

How to test state restoration

The hard part isn’t writing the code, it’s verifying it works without manually killing your app every 30 seconds. Three techniques:

1. Manual: lock the device, wait

After connecting to your peripheral:

  1. Lock the iPhone (side button)
  2. Wait 60–120 seconds
  3. Disconnect the peripheral physically (turn it off, walk out of range)
  4. Reconnect the peripheral (turn it back on)
  5. Unlock and check your app’s UI

If state restoration is working, you should see your app’s connection state caught up, possibly with a logged centralManager(_:didDisconnectPeripheral:error:) and a fresh didConnect for the auto-reconnect.

2. Programmatic: app suspend/launch via xcrun simctl

In simulator (which doesn’t actually do BLE, but does run the lifecycle):

xcrun simctl io booted suspend
xcrun simctl io booted launch com.acme.fitness com.apple.unifiedstateofevent.BluetoothCentralsKey

Useful for verifying your launch-options branch fires correctly even though no BLE happens.

3. Console logs

Stream from your iPhone:

xcrun devicectl device list  # find your device UDID
log stream --device <UDID> --predicate 'subsystem == "com.apple.bluetoothd"'

Apple’s bluetoothd daemon logs every restoration decision. You’ll see entries like:

bluetoothd: Restoring CBCentralManager <com.acme.fitness.central>
bluetoothd: Restored 1 peripherals, 1 active scans

If you don’t see those log lines after locking and unlocking, restoration isn’t happening. Go check your setup.


Skip all that: the kit version

Everything in this post is shipped in Antenna. The full state-restoration setup becomes:

let manager = BLEManager(restoreIdentifier: "com.acme.fitness.central")

// Subscribe to restoration events from anywhere in your app
for await restored in await manager.restorationEvents() {
    print("Restored \(restored.peripherals.count) peripherals from background launch")
    // Each peripheral is already in BLEManager's connection cache;
    // calling manager.connect(to:) works immediately.
}

The kit handles:

  • Setting CBCentralManagerOptionRestoreIdentifierKey correctly
  • Re-assigning peripheral delegates inside willRestoreState
  • Re-injecting restored peripherals into the connection cache so existing connect(to:) calls work without changes
  • Yielding a Sendable RestoredState value to subscribers

Plus the helper for detecting background-launch from app entry:

// In your SwiftUI App.init or AppDelegate:
if BackgroundModeHandler.wasLaunchedForBluetoothCentrals(launchOptions: launchOptions) {
    print("Background relaunched for centrals:",
          BackgroundModeHandler.bluetoothCentralIdentifiers(in: launchOptions))
}

$99 with a 30-day refund (pricing). It saves you the four-part setup and the next dozen production bugs.


Further reading

  • CBCentralManager reference (Apple) — the canonical-but-thin source
  • WWDC 2017 Session 712, “Advances in Core Bluetooth” — the only WWDC talk that mentions state restoration in depth (only relevant ~10 minutes)
  • The CoreBluetooth headers themselves (#import <CoreBluetooth/CBCentralManagerConstants.h>) — Apple’s most precise prose, ironically, is in the C-API doc comments
  • Antenna source on GitHub — the implementation discussed above (private; access via the kit purchase)

Published 2026-05-22 by Shailendra Kumar Ram. Maintainer of Antenna. Six years of shipping production CoreBluetooth at three hardware companies, including a smart-lock app whose users would not have tolerated “doesn’t reconnect after lock screen” for one minute.