Core Bluetooth stopped relaunching my app, because for five seconds nothing was pending

One of my side projects sits on my phone doing nothing most of the day. It waits for a Bluetooth bathroom scale to wake up, reads the weigh-in, and writes it to HealthKit. No launch, no tap. iOS relaunches the app from terminated when the scale powers up its radio — a documented Core Bluetooth feature, and the whole point of the thing.

About a week after I shipped an unrelated change, the background readings stopped. Opening the app by hand worked every time: connect, read, write, correct number on the screen. Left alone, nothing, for days, and it never recovered on its own.

That combination is the diagnosis, so I will put it first. If opening the app always works, the background never does, and it never fixes itself, then you are not holding the pending request iOS needs in order to wake you.

Only a pending request gets you relaunched

The mechanism is Core Bluetooth state restoration, and its contract is narrower than it tends to be described. Apple's QA1962 is unambiguous:

Also, it is important to keep in mind that the app will be relaunched and restored if and only if it is pending on a specific Bluetooth event or action (like scanning, connecting, or a subscribed notification characteristic), and this event has occurred.

ELI5: what is state restoration?

Normally an app stops existing when the system reclaims it, and nothing it was waiting for survives. State restoration is a deal you opt into: you tag your Bluetooth object with an identifier, and the system remembers what you were waiting on. If it happens later, your app is started back up in the background.

So three things can wake you, and you have to be holding one of them at the instant you are terminated. My app holds exactly one: a connect to a single known peripheral that it never cancels. A Core Bluetooth connect has no timeout, so it sits there indefinitely and completes whenever the scale's radio comes up. No scan, no subscription, nothing to fall back on.

The gap I built on purpose

The change a week earlier was a battery fix. A connected scale is an awake scale — its radio stays up servicing the link for as long as a central holds it — so the app started hanging up once a weigh-in was settled, instead of holding the connection out of convenience.

A scale that is still awake resumes advertising the moment you let go, so reconnecting immediately puts you straight back on it and saves nothing. It needs a few seconds of quiet first. I gave it five, and I made the mistake of timing them myself.

// what I had: a five-second window with no pending connect in it
central.cancelPeripheralConnection(peripheral)
let work = DispatchWorkItem { [weak self] in self?.reArm() }
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work)

Read that against QA1962 and the bug is plain. For those five seconds the app is pending on nothing, so there is nothing to preserve. A termination inside that window does not degrade the feature, it ends it: with nothing pending, iOS has no reason to relaunch the app again, and only somebody opening it by hand puts it back on watch.

The window also lands in the worst available place. This code usually runs in a process iOS woke up for a Bluetooth event and would quite like back.

And the reason it took a week to notice is the same fact seen from the user's side. Checking it fixes it. Every time I opened the app to see whether it was working, I re-armed the connect, and it was.

The assertion I added to protect that window made it worse

I had seen the risk when I wrote it, and covered it the obvious way: ask UIKit for a little more runtime, so the process survives long enough to re-arm.

ELI5: what's a background task assertion?

When an app goes to the background, the system suspends it fairly quickly. An assertion is a request for a short extension — don't freeze me yet, I am in the middle of something — and it comes with an obligation to say when you are finished. If you never say, the system stops trusting you and kills the app.

// also what I had, and this is the part that bites
bgTask = UIApplication.shared.beginBackgroundTask(withName: "sleep-grace") { [weak self] in
    DispatchQueue.main.async {   // this never runs
        self?.reArm()
        self?.endGraceTask()     // so the task is never ended
    }
}

Apple's reference page for beginBackgroundTask(expirationHandler:) rules that out in two consecutive sentences:

Failure to end the task explicitly will result in the termination of the app. The system calls the handler synchronously on the main thread, blocking the app’s suspension momentarily.

The handler is not merely running on the main thread. The handler is the thing holding suspension off, and it stops holding it the moment it returns — so anything it dispatches asynchronously to that same queue is queued behind its own return. The task is never ended, and the app is terminated for not ending it, at the precise moment there was nothing pending to relaunch it for.

An assertion I added to make termination less likely was causing it.

There is a system log for this, and it is the only outward sign. A developer on the forums posted it in 2023, from the same shape of handler, and never got an answer naming the cause:

[BackgroundTask] Background task still not ended after expiration handlers
were called: … This app will likely be terminated by the system. Call
UIApplication.endBackgroundTask(_:) to avoid this.

Apple's conceptual article on this gets it right without saying why: its sample handler calls endBackgroundTask directly, no hop. The reason is a parameter description on a different page.

It is a cousin of a teardown handed to queue.async { [weak self] } that may never run — but there the object dies, and here it is the permission that expires.

Core Bluetooth already had the primitive

The fix was a connect option I did not know existed. CBConnectPeripheralOptionStartDelayKey asks the system to wait before completing a connection.

// re-arm immediately, and let the system sit on it
central.connect(peripheral, options: [
    CBConnectPeripheralOptionStartDelayKey: NSNumber(value: 5)
])

Its reference page is two sentences long and does not mention the property that makes it useful here. For that I had to go back to a 2017 forum thread, where somebody asked what the undocumented new key was for and an Apple engineer explained it supports talking to a peripheral periodically without holding a connection open: hang up, then reconnect after the delay.

The pending connect exists from the moment you ask for it, and a pending connection is exactly what the restored state carries across termination. So the wake path is never broken and the app does not have to survive the window — the system holds the timer. The scale still gets its five quiet seconds, because what the system defers is completing the connect, not holding it.

That deleted the work item, the assertion, its expiration handler, and the "sleeping" state they needed between them.

The same mistake one layer up

While I was in there I found the gap again, somewhere I had never thought of as a background path. The CBCentralManager was built lazily, from a SwiftUI .task, after an await on HealthKit authorization.

centralManager(_:willRestoreState:) is the first delegate method Core Bluetooth calls on a restored central, before centralManagerDidUpdateState. It can only call it on a central that already exists.

A relaunch for a Bluetooth event has no window and no scene, so a @StateObject, a .task or an onAppear is not a place that launch can be relied on to reach. And an await between launch and the central existing is a suspension point in the one path that must not have one. The coordinator became a singleton for the same reason, so nothing about its lifetime waits on SwiftUI.

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    var body: some Scene { WindowGroup { ContentView() } }
}

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ app: UIApplication,
        didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        Coordinator.shared.startBluetooth()   // builds the central; nothing awaits above it
        return true
    }
}

Unconditionally, on every launch. There is no way to be cleverer than that, because Apple's note on the restore identifier key is explicit that a SwiftUI app cannot tell this is the Bluetooth launch:

In scene-based apps that adopt UISceneDelegate, launchOptions is always nil on launch, so UIApplicationLaunchOptionsBluetoothCentralsKey is not available to deliver identifiers.

Only the authorization prompts stayed behind the scene; those genuinely do need a foreground app.

What I'd tell you to check first

Go looking for windows where your app holds no pending request, and treat each as the feature being off rather than a risk to it. A disconnect you perform deliberately is the easiest way to open one without noticing.

If you need a delay, make the system hold it. A timer of your own only runs while your process does, which in the background is the one assumption you cannot make.

Never dispatch asynchronously out of an expiration handler. Do the cleanup and end the task before returning, whatever thread you think you ought to be on.

And build the central in didFinishLaunchingWithOptions, before the first await. A background relaunch arrives at the app delegate and nowhere else.

All four are in, on iOS 27, in an app targeting iOS 17 and up. On November 14 somebody stood on the scale with the app closed, and the weigh-in arrived in HealthKit on its own.