Loading...
Loading...
Use when implementing BGTaskScheduler, debugging background tasks that never run, understanding why tasks terminate early, or testing background execution - systematic task lifecycle management with proper registration, expiration handling, and Swift 6 cancellation patterns
npx skill4agent add charleswiltgen/axiom axiom-background-processingaxiom-energysubmit()axiom-energy<!-- Required in Info.plist -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourapp.refresh</string>
<string>com.yourapp.processing</string>
</array>
<!-- For BGAppRefreshTask -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<!-- For BGProcessingTask (add to UIBackgroundModes) -->
<array>
<string>fetch</string>
<string>processing</string>
</array>// ✅ CORRECT: Register in didFinishLaunchingWithOptions
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
// Safe force cast: identifier guarantees BGAppRefreshTask type
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true // Register BEFORE returning
}
// ❌ WRONG: Registering after launch or on-demand
func someButtonTapped() {
// TOO LATE - registration won't work
BGTaskScheduler.shared.register(...)
}subsystem:com.apple.backgroundtaskschedulerNeed to run code in the background?
│
├─ User initiated the action explicitly (button tap)?
│ ├─ iOS 26+? → BGContinuedProcessingTask (Pattern 4)
│ └─ iOS 13-25? → beginBackgroundTask + save progress (Pattern 5)
│
├─ Keep content fresh throughout the day?
│ ├─ Runtime needed ≤ 30 seconds? → BGAppRefreshTask (Pattern 1)
│ └─ Need several minutes? → BGProcessingTask with constraints (Pattern 2)
│
├─ Deferrable maintenance work (DB cleanup, ML training)?
│ └─ BGProcessingTask with requiresExternalPower (Pattern 2)
│
├─ Large downloads/uploads?
│ └─ Background URLSession (Pattern 6)
│
├─ Triggered by server data changes?
│ └─ Silent push notification → fetch data → complete handler (Pattern 7)
│
└─ Short critical work when app backgrounds?
└─ beginBackgroundTask (Pattern 5)| Type | Runtime | When Runs | Use Case |
|---|---|---|---|
| BGAppRefreshTask | ~30 seconds | Based on user app usage patterns | Fetch latest content |
| BGProcessingTask | Several minutes | Device charging, idle (typically overnight) | Maintenance, ML training |
| BGContinuedProcessingTask | Extended | System-managed with progress UI | User-initiated export/publish |
| beginBackgroundTask | ~30 seconds | Immediately when backgrounding | Save state, finish upload |
| Background URLSession | As needed | System-friendly time, even after termination | Large transfers |
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true
}func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh")
// earliestBeginDate = MINIMUM delay, not exact time
// System may run hours later based on usage patterns
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // At least 15 min
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule refresh: \(error)")
}
}
// Call when app enters background
func applicationDidEnterBackground(_ application: UIApplication) {
scheduleAppRefresh()
}
// Or with SceneDelegate / SwiftUI
.onChange(of: scenePhase) { newPhase in
if newPhase == .background {
scheduleAppRefresh()
}
}func handleAppRefresh(task: BGAppRefreshTask) {
// 1. IMMEDIATELY set expiration handler
task.expirationHandler = { [weak self] in
// Cancel any in-progress work
self?.currentOperation?.cancel()
}
// 2. Schedule NEXT refresh (continuous refresh pattern)
scheduleAppRefresh()
// 3. Do the work
fetchLatestContent { [weak self] result in
switch result {
case .success:
task.setTaskCompleted(success: true)
case .failure:
task.setTaskCompleted(success: false)
}
}
}setTaskCompletedBGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.maintenance",
using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}func scheduleMaintenanceIfNeeded() {
// Be conscientious — only schedule when work is actually needed
guard needsMaintenance() else { return }
let request = BGProcessingTaskRequest(identifier: "com.yourapp.maintenance")
// CRITICAL: Set requiresExternalPower for CPU-intensive work
request.requiresExternalPower = true
// Optional: Require network for cloud sync
request.requiresNetworkConnectivity = true
// Don't set earliestBeginDate too far — max ~1 week
// If user doesn't return to app, task won't run
do {
try BGTaskScheduler.shared.submit(request)
} catch BGTaskScheduler.Error.unavailable {
print("Background processing not available")
} catch {
print("Failed to schedule: \(error)")
}
}func handleMaintenance(task: BGProcessingTask) {
var shouldContinue = true
task.expirationHandler = { [weak self] in
shouldContinue = false
self?.saveProgress() // Save partial progress!
}
Task {
do {
// Process in chunks, checking for expiration
for chunk in workChunks {
guard shouldContinue else {
// Expiration called — stop gracefully
break
}
try await processChunk(chunk)
saveProgress() // Checkpoint after each chunk
}
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}requiresExternalPower = trueearliestBeginDate@main
struct MyApp: App {
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .background {
scheduleAppRefresh()
}
}
// Handle app refresh
.backgroundTask(.appRefresh("com.yourapp.refresh")) {
// Schedule next refresh
scheduleAppRefresh()
// Async work — task completes when closure returns
await fetchLatestContent()
}
// Handle background URLSession events
.backgroundTask(.urlSession("com.yourapp.downloads")) {
// Called when background URLSession completes
await processDownloadedFiles()
}
}
}setTaskCompleted// 1. Info.plist — use wildcard for dynamic suffix
// BGTaskSchedulerPermittedIdentifiers:
// "com.yourapp.export.*"
// 2. Register WHEN user initiates action (not at launch)
func userTappedExportButton() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.export.photos"
) { task in
let continuedTask = task as! BGContinuedProcessingTask
self.handleExport(task: continuedTask)
}
// Submit immediately
let request = BGContinuedProcessingTaskRequest(
identifier: "com.yourapp.export.photos",
title: "Exporting Photos",
subtitle: "0 of 100 photos"
)
// Optional: Fail if can't start immediately
request.strategy = .fail // or .enqueue (default)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
showError("Cannot export in background right now")
}
}
// 3. Handler with mandatory progress reporting
func handleExport(task: BGContinuedProcessingTask) {
var shouldContinue = true
task.expirationHandler = {
shouldContinue = false
}
// MANDATORY: Report progress (tasks with no progress auto-expire)
task.progress.totalUnitCount = 100
task.progress.completedUnitCount = 0
Task {
for (index, photo) in photos.enumerated() {
guard shouldContinue else { break }
await exportPhoto(photo)
// Update progress — system shows this to user
task.progress.completedUnitCount = Int64(index + 1)
}
task.setTaskCompleted(success: shouldContinue)
}
}.failvar backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
// Start background task
backgroundTaskID = application.beginBackgroundTask(withName: "Save State") { [weak self] in
// Expiration handler — clean up and end task
self?.saveProgress()
if let taskID = self?.backgroundTaskID {
application.endBackgroundTask(taskID)
}
self?.backgroundTaskID = .invalid
}
// Do critical work
saveEssentialState { [weak self] in
// End task as soon as done — DON'T wait for expiration
if let taskID = self?.backgroundTaskID, taskID != .invalid {
UIApplication.shared.endBackgroundTask(taskID)
self?.backgroundTaskID = .invalid
}
}
}endBackgroundTask// 1. Create background configuration
lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.yourapp.downloads"
)
config.sessionSendsLaunchEvents = true // App relaunched when complete
config.isDiscretionary = true // System chooses optimal time
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
// 2. Start download
func downloadFile(from url: URL) {
let task = backgroundSession.downloadTask(with: url)
task.resume()
}
// 3. Handle app relaunch for session events (AppDelegate)
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
// Store completion handler — call after processing events
backgroundSessionCompletionHandler = completionHandler
// Session delegate methods will be called
}
// 4. URLSessionDelegate
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// All events processed — call stored completion handler
DispatchQueue.main.async {
self.backgroundSessionCompletionHandler?()
self.backgroundSessionCompletionHandler = nil
}
}
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// Move file from temp location before returning
let destinationURL = getDestinationURL(for: downloadTask)
try? FileManager.default.moveItem(at: location, to: destinationURL)
}nsurlsessiondisDiscretionary = truehandleEventsForBackgroundURLSession{
"aps": {
"content-available": 1
},
"custom-data": "fetch-new-messages"
}apns-priority: 5func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
Task {
do {
let hasNewData = try await fetchLatestData()
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}func handleAppRefresh(task: BGAppRefreshTask) {
// Create a Task that respects expiration
let workTask = Task {
try await withTaskCancellationHandler {
// Your async work
try await fetchAndProcessData()
task.setTaskCompleted(success: true)
} onCancel: {
// Called synchronously when task.cancel() is invoked
// Note: Runs on arbitrary thread, keep lightweight
}
}
// Bridge expiration to cancellation
task.expirationHandler = {
workTask.cancel() // Triggers onCancel block
}
}
// Checking cancellation in your work
func fetchAndProcessData() async throws {
for item in items {
// Check if we should stop
try Task.checkCancellation()
// Or non-throwing check
guard !Task.isCancelled else {
saveProgress()
return
}
try await process(item)
}
}withTaskCancellationHandlerTask.checkCancellation()CancellationErrorTask.isCancelled// Trigger task launch
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
// Trigger task expiration (test expiration handler)
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]_simulateLaunchForTaskWithIdentifier_simulateExpirationForTaskWithIdentifiersetTaskCompleted| Factor | Description | Impact |
|---|---|---|
| Critically Low Battery | <20% battery | All discretionary work paused |
| Low Power Mode | User-enabled | Background activity limited |
| App Usage | How often user launches app | More usage = higher priority |
| App Switcher | App still visible? | Swiped away = no background |
| Background App Refresh | System setting | Off = no BGAppRefresh tasks |
| System Budgets | Energy/data budgets | Deplete with launches, refill over day |
| Rate Limiting | System spacing | Prevents too-frequent launches |
// Check Low Power Mode
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// Reduce background work
}
// Listen for changes
NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
.sink { _ in
// Adapt behavior
}
// Check Background App Refresh status
let status = UIApplication.shared.backgroundRefreshStatus
switch status {
case .available:
break // Good to schedule
case .denied:
// User disabled — prompt to enable in Settings
case .restricted:
// Parental controls or MDM — can't enable
}fetchprocessingdidFinishLaunchingWithOptionsearliestBeginDatesubmit()getPendingTaskRequestssetTaskCompleted(success:)requiresExternalPower = truebackgroundRefreshStatus// In didFinishLaunchingWithOptions
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
task.setTaskCompleted(success: true) // Placeholder
self.scheduleRefresh()
}submit()// Code uses:
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.myapp.Refresh", // Capital R
...
)
// Info.plist has:
// "com.myapp.refresh" // lowercase rfunc handleRefresh(task: BGAppRefreshTask) {
fetchData { result in
switch result {
case .success:
task.setTaskCompleted(success: true) // ✅ Called
case .failure:
// ❌ Missing setTaskCompleted!
print("Failed")
}
}
}setTaskCompletedcase .failure:
task.setTaskCompleted(success: false) // ✅ Now calledUser: "I close my apps every night to save battery."
Developer: "How do you close them?"
User: "Swipe up in the app switcher."requiresExternalPower = truelet request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
// Missing: request.requiresExternalPower = truerequiresExternalPower// Trigger task
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"IDENTIFIER"]
// Trigger expiration
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"IDENTIFIER"]subsystem:com.apple.backgroundtaskscheduler| Need | Use | Runtime |
|---|---|---|
| Keep content fresh | BGAppRefreshTask | ~30s |
| Heavy maintenance | BGProcessingTask + requiresExternalPower | Minutes |
| User-initiated continuation | BGContinuedProcessingTask (iOS 26) | Extended |
| Finish on background | beginBackgroundTask | ~30s |
| Large downloads | Background URLSession | As needed |
| Server-triggered | Silent push notification | ~30s |