BGTaskScheduler on iOS: why a scheduled task doesn't run when you scheduled it
The first time we implement background refresh in an iOS app, the expectation is usually the same: schedule a BGAppRefreshTaskRequest with earliestBeginDate set fifteen minutes out, and assume the task will fire around then. On real devices that almost never happens. The task might run half an hour later, six hours later, or not at all that day if the user hasn't opened the app. earliestBeginDate is exactly what the name says: the earliest point at which the task could run, not the point at which it will run. Everything else about the decision — when, or whether it happens at all — belongs to iOS, based on criteria the app has no visibility into and that can shift between OS versions.
What the system checks before it wakes your app
BGTaskScheduler doesn't distribute background execution time evenly across installed apps. The system keeps a per-app background execution budget, and that budget adjusts based on usage patterns: an app the user opens frequently, at predictable times of day, gets more background opportunities than one the user opened once and never touched again. The same code, with the same earliestBeginDate, behaves differently depending on the device and that particular user's history, and there's no way to force that behavior from inside the app.
Beyond usage history, the type of task matters too. BGAppRefreshTaskRequest is meant for short, frequent updates — refreshing a feed, syncing lightweight content — and the system is conservative about how much time it hands out for those. BGProcessingTaskRequest is meant for longer, deferrable work — reindexing a local database, training a model, clearing a cache — and it accepts additional conditions the system will actually honor before running the task:
let request = BGProcessingTaskRequest(identifier: "com.tuurt.app.maintenance")
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true
try? BGTaskScheduler.shared.submit(request)
With requiresExternalPower set to true, that maintenance task won't run until the device is charging, no matter how much time has passed since earliestBeginDate. It's a constraint worth using surgically: it makes sense for heavy work that genuinely shouldn't compete for battery, but every extra condition narrows the windows in which the system considers the task worth running at all.
Registration has to happen before launch finishes
A common, silent mistake is registering the task handler at a point in the app lifecycle that runs after application(_:didFinishLaunchingWithOptions:) returns. BGTaskScheduler.shared.register(forTaskWithIdentifier:using:launchHandler:) has to execute during that method, not in a viewDidLoad or some deferred call. If registration happens too late, the system simply has no handler tied to that identifier when it decides to launch the task, and nothing runs — with no error in the console pointing at the actual cause.
The identifier itself also has to be declared in Info.plist, under the BGTaskSchedulerPermittedIdentifiers key, and match exactly the string used in both register and the request's identifier. A casing mismatch or a typo across those three places produces the same symptom as late registration: the task never fires, with nothing in the logs to point at why.
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.tuurt.app.refresh", using: nil) { task in
self.handleRefresh(task: task as! BGAppRefreshTask)
}
return true
}
Designing the task assuming it might not finish
Every task gets an expirationHandler that the system calls when its allotted time runs out before the task calls setTaskCompleted. If the task doesn't handle that expiration — canceling the in-flight work and calling setTaskCompleted(success: false) — the system kills it anyway, but it also penalizes that identifier for future launches, because from the system's point of view the app is asking for execution time it isn't managing responsibly.
func handleRefresh(task: BGAppRefreshTask) {
scheduleNextRefresh()
let operation = SyncContentOperation()
task.expirationHandler = {
operation.cancel()
}
operation.completionBlock = {
task.setTaskCompleted(success: !operation.isCancelled)
}
OperationQueue().addOperation(operation)
}
Notice that scheduleNextRefresh() is called at the top of the handler, not at the end. That's not a style choice: if the current task expires, or the process gets terminated before reaching the end of the handler, the next request is already queued. Waiting until the success block to schedule the next task leaves the app with nothing scheduled at all if the current run gets interrupted, and there's no automatic second chance to fix that.
What didn't work: assuming a cadence and building a feature on top of it
On one project we built a draft-syncing feature that relied on BGAppRefreshTaskRequest firing roughly every fifteen to twenty minutes while the app was installed, so a draft written on one device would show up on the others fairly quickly. It worked well in internal testing, where the team opened the app several times a day and the system, as a result, handed out frequent execution windows. In production, with users opening the app once or twice a week, the same code left drafts unsynced for days, because the background budget the system granted for that usage pattern was far smaller than what we'd seen in development. It wasn't a bug — it was the framework's documented behavior working exactly as designed, applied to a use case that needed a timing guarantee BGTaskScheduler never offered.
The fix wasn't tuning request parameters, because no parameter turns background execution into guaranteed execution. We moved draft syncing to two different triggers instead: an immediate sync when the app comes to the foreground, which covers the common case, and a silent push notification (content-available) for when another device needs to learn about a change without the user ever opening the app. We kept BGProcessingTaskRequest only for maintenance work where a delay doesn't directly affect any user — compressing old cache files, rebuilding local search indexes — which is exactly the kind of task the framework was built for.
What we check before trusting a background task
Before we hand user-visible functionality over to BGTaskScheduler, we verify that handler registration happens during app launch and not later, that the identifier matches exactly across Info.plist, register, and the request, that the next request gets scheduled at the start of the handler rather than the end, and that the expirationHandler actually cancels in-flight work instead of leaving it running unsupervised. Above all, we check that the feature can tolerate the task taking hours to run, or not running at all for a full day, because that's the real guarantee the framework offers: best-effort execution when the system decides it's worthwhile, never a fixed cadence the app can take for granted.