Why Core Bluetooth state restoration never fires

21 September 2026

Is Bluetooth State Restoration broken?

You follow the documentation, you set CBCentralManagerOptionRestoreIdentifierKey, the system terminates your app in the background, the accessory sends something, and the app does not come back. The conclusion most people reach at that point is that state restoration is broken. But it almost never is.

State restoration has strict rules, and if any one of them is not met, the app will not be relaunched. Apple covers them in the Core Bluetooth Programming Guide, in technote TN3115, and in the API reference for the restoration keys and delegate methods. This article puts them together in one place.

How restoration works

An app running in the background is sooner or later going to be terminated by the system, to free up memory for something else. If the app's Core Bluetooth managers opted in to state restoration, the system holds on to their state when that happens: the scans in progress, the pending connections, the connected peripherals and the characteristics the app subscribed to. When a Bluetooth event arrives that the app was waiting for, the system launches the app in the background, hands that state back, and delivers the event. The Core Bluetooth Programming Guide describes the mechanism, and gives an app woken up this way about 10 seconds to deal with the event.

So restoration is a relaunch for a specific Bluetooth event. If the app was not waiting for any event when it was terminated, the system has no reason to bring it back.

What your app has to do

There are four requirements on the app side, and you need all of them.

1. Declare the background mode

Add bluetooth-central to UIBackgroundModes if the app is a central, bluetooth-peripheral if it is a peripheral, or both if it is both:

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

2. Use the same restoration identifier on every launch

The documentation for the key says the identifier has to be identical across executions of the app, because that is how the system matches the state it saved to the manager you create. If you generate a UUID at launch, or build the string from anything that can change, the system has nothing to match it to.

3. Create the manager at every launch

The programming guide was written before scenes, and its example looks in launchOptions for UIApplicationLaunchOptionsBluetoothCentralsKey to find out which managers to recreate. In a scene-based app launchOptions is always nil, and with the iOS 27 SDK every app is scene-based. The key's documentation describes the current way: persist the identifier yourself, and create the manager with it on every launch, whatever the launch was for. I have a note on launchOptions that covers the rest of the keys that went the same way.

4. Implement centralManager(_:willRestoreState:)

When the system relaunches the app for restoration, this is the first CoreBluetooth delegate method it calls, before centralManagerDidUpdateState(_:). The dictionary it passes holds the peripherals that were connected or had a connection pending, and the services the manager was scanning for. You will want to keep those peripherals and set yourself as their delegate again. If you do not, their events have nowhere to go.

Important: sometimes the system will know to relaunch your app but the parameters passed to willRestoreState may be empty. In that case you will need to restore the state of your app/centrals/peripherals yourself, or start discovery from scratch

Here is a central that covers all four:

import CoreBluetooth

/// Owns the central manager. Created at every launch, whatever the reason
/// for the launch, so a restoration has something to restore into.
final class Bluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    /// The same string on every launch, or restoration finds nothing.
    static let restoreID = "com.example.app.central"

    private var central: CBCentralManager?
    private var known: [UUID: CBPeripheral] = [:]

    override init() {
        super.init()
        central = CBCentralManager(
            delegate: self, queue: nil,
            options: [CBCentralManagerOptionRestoreIdentifierKey: Bluetooth.restoreID])
    }

    func centralManager(_ central: CBCentralManager,
                        willRestoreState state: [String: Any]) {
        // On a relaunch, this comes first. Take the peripherals back, and
        // be their delegate again, or their events go nowhere.
        let restored = state[CBCentralManagerRestoredStatePeripheralsKey]
            as? [CBPeripheral] ?? []
        for peripheral in restored {
            peripheral.delegate = self
            known[peripheral.identifier] = peripheral
        }
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        guard central.state == .poweredOn else { return }
        // Pick up where the app left off. A pending connect is what gets
        // the app relaunched next time.
        for peripheral in known.values where peripheral.state == .disconnected {
            central.connect(peripheral)
        }
    }

    func centralManager(_ central: CBCentralManager,
                        didDisconnectPeripheral peripheral: CBPeripheral,
                        error: (any Error)?) {
        // Ask again straight away. The request stays pending while the app
        // is suspended or terminated, and the connection relaunches it.
        central.connect(peripheral)
    }
}

And the app delegate, which creates it on every launch:

import UIKit

final class AppDelegate: NSObject, UIApplicationDelegate {
    private var bluetooth: Bluetooth?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions
            launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Every launch. In a scene-based app launchOptions is nil, so
        // there's no Bluetooth key to check, and nothing to check it for.
        bluetooth = Bluetooth()
        return true
    }
}

When the system relaunches the app

Apple's TN3115 spells out the first condition. The app is relaunched if, and only if, it was waiting for a specific Bluetooth event when it was terminated, and that event then happened. For a central that means scanning, a pending connection, or a characteristic the app subscribed to. For a peripheral it means advertising. An app that was connected to an accessory and doing nothing else is relaunched when the accessory disconnects, or when it sends a notification on a characteristic the app subscribed to.

The second condition is how the app stopped, and what the person has done since. TN3115 has the full list, including what happens after a restart. A simpler way to remember it is to ask who stopped the app. When the system terminates it, or it crashes, it comes back. When the person stops it, or stops Bluetooth, it may not. And if the app was only suspended, it is resumed rather than relaunched, so willRestoreState is not called at all.

The case that catches most people is the force-quit, because swiping the app away in the app switcher is also how most people try to test this. Once the person force-quits the app, the system does not relaunch it for Bluetooth events until they open it again.

Turning Bluetooth off is the other one, and it helps to know that there are two ways to do it. Turning it off in Settings, or with an Airplane Mode that includes Bluetooth, powers the radio off, and no app is relaunched after that, whatever it was waiting for. The Bluetooth button in Control Center does something else: Apple's support article describes it as disconnecting accessories while Bluetooth stays on.

Starting with iOS 26, apps that set up their accessories with AccessorySetupKit are relaunched after a force-quit and after the Control Center button, and other apps are not. Powering the radio off still ends restoration for everyone. If you are building an accessory app and have not looked at AccessorySetupKit yet, you should.

Before you blame restoration

Make sure the app was actually terminated. From the accessory's side, an app that is suspended, or still running but stuck somewhere it does not accept a connection, looks the same as one that was terminated and never came back. Log something in application(_:didFinishLaunchingWithOptions:) and in centralManager(_:willRestoreState:). If neither shows up after the event, the app was never relaunched, and the next thing to check is whether it was ever terminated in the first place.

If your app is the peripheral, keep in mind what happens to advertising in the background. The programming guide says the local name is not advertised, the service UUIDs go into an overflow area that only an iOS device explicitly scanning for them can see, and advertising may slow down when every advertising app is in the background. An accessory that looks for your app's local name will not find it while the app is in the background, whether it was terminated or not.

Testing it

Do not test this from Xcode. Under the debugger the app is not suspended or terminated the way it is in normal use, so you end up testing something that never happens on a customer's phone. Install the build, launch it from the Home Screen, and do not swipe it away, because that is the force-quit case above.

To terminate the app when you want to, give your debug build a button that kills the process. TN3115 says a crashed app is relaunched, and this is as close to a crash as you can get on purpose:

#if DEBUG
/// Debug builds only: end the process the way a crash does, not the way
/// a swipe in the app switcher does.
func terminateForRestorationTest() {
    kill(getpid(), SIGKILL)
}
#endif

Important: it is very easy to make a mistake a ship the kill code in production. So take utmost care that this code branch only exists in your debug builds.

Get a connection pending, press the button, then have the accessory do the thing the app is waiting for. If the app does not come back, install the Bluetooth logging profile before you reproduce it again, and take a sysdiagnose right after. The profile has to be installed before the problem happens, and I have a note on why.

If the person force-quits the app

On iOS 26 and later, AccessorySetupKit is the way to stay relaunchable. Without it, the most an app can do is let the person know. Schedule a local notification a few minutes ahead, and move it further ahead every time the app gets to run. As long as the app keeps running, the notification never fires. If the app is gone, the notification fires and asks the person to open the app again. It is a commonly used workaround.

Sources