CoreBluetooth state restoration, and why your peripheral drops when the app suspends

ยท 9 min read

The app works perfectly with the screen on. The tester locks the phone, comes back ten minutes later, and the device is disconnected. This is almost never one bug. It is a stack of four requirements where missing any one produces the same symptom, which is why it takes so long to pin down.

First work out which side dropped the connection

Before touching restoration, find out whether iOS suspended you or the peripheral hung up. The error passed to didDisconnectPeripheral tells you, and most people never read it:

func centralManager(_ central: CBCentralManager,
                    didDisconnectPeripheral peripheral: CBPeripheral,
                    error: Error?) {
    // nil  -> your app called cancelPeripheralConnection. You did this.
    // 6    -> CBError.connectionTimeout, the link supervision timeout expired.
    // 7    -> peripheralDisconnected, the device initiated it. Firmware's call.
    // 10   -> connectionFailed
    print("disconnect:", (error as NSError?)?.code ?? "clean", error ?? "")
}

Code 7 means the firmware disconnected you and no amount of iOS work will fix it. Plenty of devices have an idle timeout that closes the link after sixty or ninety seconds with no activity, which looks exactly like a backgrounding bug from the app side. Ask the firmware team what their idle policy is before you spend a week on restoration.

The three things restoration needs, all of them

State restoration is what lets iOS relaunch your app in the background after terminating it, and hand back the connection it was holding on your behalf. It needs three separate pieces and fails silently without any of them.

  • The bluetooth-central background mode declared in Info.plist under UIBackgroundModes.
  • A restore identifier passed when you construct the central manager, stable across every launch of the app.
  • An implementation of centralManager(_:willRestoreState:), which the system calls before anything else.
// The identifier is a promise to the system. Change it between builds and
// iOS treats it as a different manager, so there is nothing to restore.
central = CBCentralManager(
    delegate: self,
    queue: nil,
    options: [CBCentralManagerOptionRestoreIdentifierKey: "com.example.central"]
)

Construct the manager at launch, unconditionally. The common mistake is creating it lazily, the first time the user taps Connect. On a background relaunch nobody taps anything, so the manager is never built, willRestoreState is never called, and the restored connection is discarded a few seconds later.

The bug that catches most people: you stopped holding the peripheral

This one accounts for more mysterious disconnects than everything else here combined. CBCentralManager does not retain the peripherals you connect to. If the only strong reference lives in a view controller, and that controller goes away, the CBPeripheral deallocates and the connection goes with it.

The symptom is maddening because it is timing-dependent. It survives a quick background and foreground, then fails when the phone stays locked long enough for the view to be torn down. Keep the peripheral on something that lives as long as the app does.

What willRestoreState has to do

The system calls this before centralManagerDidUpdateState, and it hands back the peripherals it was managing for you. Two jobs matter here: take a strong reference, and reattach the delegate. A restored peripheral arrives with its delegate set to nil, so every callback you were relying on silently stops.

func centralManager(_ central: CBCentralManager,
                    willRestoreState dict: [String: Any]) {
    let restored = dict[CBCentralManagerRestoredStatePeripheralsKey]
        as? [CBPeripheral] ?? []

    for peripheral in restored {
        peripheral.delegate = self        // arrives nil, reattach it
        self.peripherals.insert(peripheral)  // strong reference, app lifetime
    }
    // Do not start heavy work here. A background relaunch gets a short
    // window, and the app is suspended again as soon as it goes idle.
}

Resist doing real work in this callback. You are running in a background launch with a small budget, and anything expensive here risks the system suspending you mid-task.

Restoration does not survive a force quit

If the user swipes the app away in the app switcher, iOS will not relaunch it for Bluetooth events. That is deliberate. A force quit is read as an explicit instruction to stop, and the app stays stopped until the user opens it again.

This wastes a lot of debugging time, because force quitting is exactly what a developer does between test runs. Test restoration properly instead: connect, background the app, then evict it by launching memory-heavy apps, or attach the debugger and terminate the process. Do not swipe up and conclude the feature is broken.

Reconnect with a pending connect, not a scan

For a device you already know, calling connect on it is far better than scanning for it. A pending connection has no timeout and survives app termination, so iOS holds it open and relaunches you when the device comes back into range. A background scan is slower, needs a service UUID filter to work at all, and burns battery for a job the system will do for you.

// Queued indefinitely. iOS relaunches the app when the device reappears.
central.connect(peripheral, options: [
    CBConnectPeripheralOptionNotifyOnConnectionKey: true,
    CBConnectPeripheralOptionNotifyOnDisconnectionKey: true
])

The checklist

  • bluetooth-central is in UIBackgroundModes.
  • A restore identifier is passed, and has not changed between builds.
  • The central manager is constructed at launch, not on user action.
  • willRestoreState reattaches the delegate and takes a strong reference.
  • Peripherals are retained for the life of the app, not by a view controller.
  • Reconnection uses a pending connect rather than a background scan.
  • You have confirmed with firmware that the device has no idle timeout closing the link.
  • You tested by evicting the app, not by force quitting it.
Read the disconnect error code first. Half of these investigations end there, with a firmware idle timeout nobody mentioned.

If you are stuck on a connection that will not survive backgrounding and the checklist has not settled it, get in touch and we can look at the disconnect logs together.