Legacy background fetch gave the app a single opaque callback and roughly 30 seconds whenever the system felt like invoking it, with no way to express constraints like "only when charging" or "only for longer maintenance work."
#The old way
import UIKit
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
SyncService.shared.sync { success in
completionHandler(success ? .newData : .failed)
}
}
}
#The new way
import BackgroundTasks
let refreshTaskIdentifier = "com.example.app.refresh"
func registerBackgroundTasks() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: refreshTaskIdentifier, using: nil) { task in
handleRefresh(task: task as! BGAppRefreshTask)
}
}
func scheduleRefresh() {
let request = BGAppRefreshTaskRequest(identifier: refreshTaskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)
try? BGTaskScheduler.shared.submit(request)
}
func handleRefresh(task: BGAppRefreshTask) {
scheduleRefresh()
let operation = Task {
await SyncService.shared.sync()
task.setTaskCompleted(success: true)
}
task.expirationHandler = {
operation.cancel()
}
}
#Why it matters
BGTaskSchedulercovers both short refresh tasks (BGAppRefreshTaskRequest) and longer processing tasks (BGProcessingTaskRequest, for things like database maintenance) with the same registration model- Scheduling is declarative — submit a request with constraints (earliest date, requires network, requires charging for processing tasks) and the system decides the actual moment
expirationHandlergives a clean signal to cancel in-flight work before the system kills the process outright- Replaces scattered
beginBackgroundTask/endBackgroundTaskpairs with one scheduling API for planned, recurring work
#Gotcha
earliestBeginDate is a floor, not a promise — the system weighs battery level, Background App Refresh settings, and how often the user actually opens the app, and a task can run much later than requested or not at all in a given session. Always reschedule the next request from inside the handler, as above, rather than assuming one submission recurs automatically. Testing this reliably requires triggering it manually through the debugger (Apple documents an e -l objc -- LLDB command for simulating a launch) — it won't fire naturally on a timeline short enough to test by just waiting.