ax-background-tasks
npx machina-cli add skill Kasempiternal/axiom-v2/ax-background-tasks --openclawBackground Tasks
Quick Patterns
// BGAPPREFRESH (iOS 13+) - keep content fresh, ~30s runtime
// Info.plist: BGTaskSchedulerPermittedIdentifiers + UIBackgroundModes=fetch
// Register in didFinishLaunchingWithOptions BEFORE return
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.refresh", using: nil
) { task in
self.handleRefresh(task: task as! BGAppRefreshTask)
}
// Schedule when app backgrounds
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try BGTaskScheduler.shared.submit(request)
// Handler
func handleRefresh(task: BGAppRefreshTask) {
task.expirationHandler = { self.cancel() } // set FIRST
scheduleNextRefresh() // continuous pattern
fetchData { result in
task.setTaskCompleted(success: result.isSuccess) // ALL paths
}
}
// BGPROCESSINGTASK (iOS 13+) - maintenance, minutes runtime
// Info.plist: UIBackgroundModes=processing
let req = BGProcessingTaskRequest(identifier: "com.app.maintenance")
req.requiresExternalPower = true // CPU-intensive work
req.requiresNetworkConnectivity = true // cloud sync
// BGCONTINUEDPROCESSINGTASK (iOS 26+) - user-initiated continuation
let req = BGContinuedProcessingTaskRequest(
identifier: "com.app.export.photos",
title: "Exporting Photos", subtitle: "0 of 100"
)
req.strategy = .fail // reject if can't start now
// BACKGROUND URLSESSION - survives app termination
let config = URLSessionConfiguration.background(withIdentifier: "com.app.dl")
config.sessionSendsLaunchEvents = true
config.isDiscretionary = true
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
// BEGINBACKGROUNDTASK - ~30s for state saving on background
let id = UIApplication.shared.beginBackgroundTask { UIApplication.shared.endBackgroundTask(id) }
saveState { UIApplication.shared.endBackgroundTask(id) }
// SWIFTUI backgroundTask modifier
.backgroundTask(.appRefresh("com.app.refresh")) {
scheduleNext()
await fetchContent() // completes when closure returns
}
Decision Tree
Need background execution?
|
+-- User explicitly initiated action (button tap)?
| +-- iOS 26+? --> BGContinuedProcessingTask (progress UI)
| +-- iOS 13-25? --> beginBackgroundTask + save progress
|
+-- Keep content fresh throughout day?
| +-- Work <= 30 seconds? --> BGAppRefreshTask
| +-- Need several minutes? --> BGProcessingTask with constraints
|
+-- Deferrable maintenance (DB cleanup, ML training)?
| --> BGProcessingTask with requiresExternalPower=true
|
+-- Large downloads/uploads?
| --> Background URLSession (survives app termination)
|
+-- Server triggers data fetch?
| --> Silent push notification (content-available:1)
|
+-- Short critical work when backgrounding?
| --> beginBackgroundTask (~30s)
|
Task never runs?
+-- Info.plist identifier matches code exactly (case-sensitive)?
+-- Registration in didFinishLaunchingWithOptions before return?
+-- App not swiped away from App Switcher?
+-- UIBackgroundModes includes "fetch" or "processing"?
+-- Background App Refresh enabled in Settings?
|
Task terminates early?
+-- Expiration handler set as FIRST line?
+-- setTaskCompleted called in ALL code paths?
+-- Work duration within task type limits?
+-- Using BGProcessingTask for >30s work?
|
Works in dev, not production?
+-- Low Power Mode enabled?
+-- Battery < 20%?
+-- App rarely used (low system priority)?
+-- Force-quit from App Switcher?
Anti-Patterns
// WRONG: registering after app launch
func someButtonTapped() {
BGTaskScheduler.shared.register(...) // too late!
}
// CORRECT: register in didFinishLaunchingWithOptions before return true
func application(_:didFinishLaunchingWithOptions:) -> Bool {
BGTaskScheduler.shared.register(...)
return true
}
// WRONG: missing setTaskCompleted in error path
func handleRefresh(task: BGAppRefreshTask) {
fetchData { result in
if case .success = result {
task.setTaskCompleted(success: true)
}
// failure path: NEVER signals completion!
}
}
// CORRECT: call in ALL paths
func handleRefresh(task: BGAppRefreshTask) {
fetchData { result in
task.setTaskCompleted(success: result.isSuccess)
}
}
// WRONG: no expiration handler, or set too late
func handleRefresh(task: BGAppRefreshTask) {
doWork()
task.expirationHandler = { ... } // too late if already expired!
}
// CORRECT: set expiration handler FIRST
func handleRefresh(task: BGAppRefreshTask) {
task.expirationHandler = { self.cancel() }
doWork()
}
// WRONG: expecting polling intervals in background
Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in fetchData() }
// CORRECT: BGAppRefreshTask runs on system schedule (user usage patterns)
// For real-time: use silent push notifications
// WRONG: not saving progress for long tasks
func handleMaintenance(task: BGProcessingTask) {
processAllItems() // if expired mid-way, all progress lost
}
// CORRECT: checkpoint after each chunk
func handleMaintenance(task: BGProcessingTask) {
var shouldContinue = true
task.expirationHandler = { shouldContinue = false }
for item in items {
guard shouldContinue else { saveProgress(); break }
process(item)
saveProgress() // checkpoint
}
task.setTaskCompleted(success: shouldContinue)
}
Deep Patterns
BGAppRefreshTask: Complete Implementation
// Registration (AppDelegate)
func application(_ app: UIApplication,
didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.refresh", using: nil
) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true
}
// Scheduling (on background transition)
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch BGTaskScheduler.Error.notPermitted {
// Background App Refresh disabled
} catch BGTaskScheduler.Error.tooManyPendingTaskRequests {
// Already scheduled
} catch BGTaskScheduler.Error.unavailable {
// Not available (Simulator)
} catch { print("Schedule failed: \(error)") }
}
// Handler
func handleAppRefresh(task: BGAppRefreshTask) {
task.expirationHandler = { [weak self] in
self?.currentOperation?.cancel()
}
scheduleAppRefresh() // continuous
fetchLatestContent { result in
task.setTaskCompleted(success: result.isSuccess)
}
}
BGProcessingTask: Maintenance with Checkpointing
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.maintenance", using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}
func scheduleMaintenance() {
guard needsMaintenance() else { return }
let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
request.requiresExternalPower = true // disables CPU monitor
request.requiresNetworkConnectivity = true
try? BGTaskScheduler.shared.submit(request)
}
func handleMaintenance(task: BGProcessingTask) {
var shouldContinue = true
task.expirationHandler = {
shouldContinue = false
}
Task {
for chunk in workChunks {
guard shouldContinue else { saveProgress(); break }
try await processChunk(chunk)
saveProgress()
}
task.setTaskCompleted(success: shouldContinue)
}
}
BGContinuedProcessingTask (iOS 26+)
User-initiated work with system progress UI. Dynamic registration.
// Info.plist: "com.app.export.*" (wildcard)
func userTappedExport() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.export.photos"
) { task in
self.handleExport(task: task as! BGContinuedProcessingTask)
}
let request = BGContinuedProcessingTaskRequest(
identifier: "com.app.export.photos",
title: "Exporting Photos",
subtitle: "0 of 100 photos"
)
request.strategy = .fail // or .enqueue (default)
try? BGTaskScheduler.shared.submit(request)
}
func handleExport(task: BGContinuedProcessingTask) {
var shouldContinue = true
task.expirationHandler = { shouldContinue = false }
// MANDATORY: progress reporting (no updates = auto-expire)
task.progress.totalUnitCount = Int64(photos.count)
task.progress.completedUnitCount = 0
Task {
for (i, photo) in photos.enumerated() {
guard shouldContinue else { break }
await exportPhoto(photo)
task.progress.completedUnitCount = Int64(i + 1)
}
task.setTaskCompleted(success: shouldContinue)
}
}
// Check GPU availability (iOS 26+)
if BGTaskScheduler.shared.supportedResources.contains(.gpu) { /* GPU OK */ }
SwiftUI backgroundTask Modifier
@main
struct MyApp: App {
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup { ContentView() }
.onChange(of: scenePhase) { newPhase in
if newPhase == .background { scheduleAppRefresh() }
}
.backgroundTask(.appRefresh("com.app.refresh")) {
scheduleAppRefresh()
await fetchLatestContent()
// implicit setTaskCompleted when closure returns
// automatic cancellation on expiration
}
.backgroundTask(.urlSession("com.app.downloads")) {
await processDownloadedFiles()
}
}
}
Swift Concurrency + Expiration Bridge
func handleAppRefresh(task: BGAppRefreshTask) {
let workTask = Task {
try await withTaskCancellationHandler {
try await fetchAndProcessData()
task.setTaskCompleted(success: true)
} onCancel: {
// lightweight, runs on arbitrary thread
}
}
task.expirationHandler = { workTask.cancel() }
}
func fetchAndProcessData() async throws {
for item in items {
try Task.checkCancellation() // throws CancellationError
try await process(item)
}
}
Background URLSession
lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "com.app.downloads")
config.sessionSendsLaunchEvents = true
config.isDiscretionary = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
// AppDelegate
var bgSessionHandler: (() -> Void)?
func application(_ app: UIApplication,
handleEventsForBackgroundURLSession id: String,
completionHandler: @escaping () -> Void) {
bgSessionHandler = completionHandler
}
// URLSessionDelegate
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// MUST move file immediately -- temp deleted after return
try? FileManager.default.moveItem(at: location, to: destinationURL)
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async { self.bgSessionHandler?(); self.bgSessionHandler = nil }
}
Silent Push Notification Trigger
{ "aps": { "content-available": 1 }, "custom": "data" }
Use apns-priority: 5 for energy efficiency. Rate-limited: 14 pushes may yield 7 launches.
func application(_ app: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler handler: @escaping (UIBackgroundFetchResult) -> Void) {
Task {
do {
let new = try await fetchLatestData()
handler(new ? .newData : .noData)
} catch { handler(.failed) }
}
}
beginBackgroundTask (State Saving)
var bgTaskID: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ app: UIApplication) {
bgTaskID = app.beginBackgroundTask(withName: "Save") { [weak self] in
self?.saveProgress()
if let id = self?.bgTaskID { app.endBackgroundTask(id) }
self?.bgTaskID = .invalid
}
saveState { [weak self] in
guard let self, self.bgTaskID != .invalid else { return }
UIApplication.shared.endBackgroundTask(self.bgTaskID)
self.bgTaskID = .invalid
}
}
Call endBackgroundTask as soon as work completes, not just in expiration handler.
Task Type Reference
| Type | Runtime | When Runs | Info.plist Mode |
|---|---|---|---|
| BGAppRefreshTask | ~30s | User usage patterns | fetch |
| BGProcessingTask | Minutes | Charging, idle (overnight) | processing |
| BGContinuedProcessingTask | Extended | User-initiated (iOS 26+) | processing |
| beginBackgroundTask | ~30s | Immediately on background | None |
| Background URLSession | As needed | System-optimal, survives termination | None |
| Silent push | ~30s | Server-triggered | remote-notification |
The 7 Scheduling Factors (WWDC 2020-10063)
| Factor | Impact |
|---|---|
| Critically Low Battery (<20%) | Discretionary work paused |
| Low Power Mode | Background activity limited |
| App Usage | More frequent = higher priority |
| App Switcher | Swiped away = no background |
| Background App Refresh setting | Off = no BGAppRefresh |
| System Budgets | Deplete with launches, refill daily |
| Rate Limiting | System spaces launches |
Info.plist Configuration
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.app.refresh</string>
<string>com.app.maintenance</string>
<string>com.app.export.*</string> <!-- wildcard, iOS 26+ -->
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
Diagnostics
LLDB Testing Commands
// Trigger task launch (pause debugger first)
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.app.refresh"]
// Trigger expiration
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.app.refresh"]
Console Filter
subsystem:com.apple.backgroundtaskscheduler
Expected log sequence: Registered handler > Scheduling task > Starting task > Task completed.
Check Pending Tasks
BGTaskScheduler.shared.getPendingTaskRequests { requests in
for r in requests { print("Pending: \(r.identifier), earliest: \(r.earliestBeginDate ?? Date())") }
}
System Constraint Checks
// Low Power Mode
ProcessInfo.processInfo.isLowPowerModeEnabled
// Background App Refresh
UIApplication.shared.backgroundRefreshStatus // .available, .denied, .restricted
// Thermal state
ProcessInfo.processInfo.thermalState // .nominal, .fair, .serious, .critical
Task Never Runs Checklist
- Info.plist identifier matches code exactly (case-sensitive)
- UIBackgroundModes includes
fetchand/orprocessing - Registration in
didFinishLaunchingWithOptionsbeforereturn true - App not swiped away from App Switcher
- Background App Refresh enabled in Settings
- Battery > 20%, Low Power Mode off
- LLDB
_simulateLaunchForTaskWithIdentifiertriggers handler
Task Terminates Early Checklist
- Expiration handler set as FIRST line in handler
setTaskCompleted(success:)called in ALL code paths- Work duration within task type limits (~30s refresh, minutes processing)
- Network operations use background URLSession for large transfers
- Expiration handler actually cancels in-progress work
Works in Dev, Not Production
- Debugger attached changes timing behavior
- Check
ProcessInfo.isLowPowerModeEnabled - Check
UIApplication.backgroundRefreshStatus - User may have force-quit from App Switcher
- Rarely-used apps get lower scheduling priority
- Add analytics logging for schedule/launch/complete events
File Protection for Background Tasks
Files must be accessible when device is locked:
try data.write(to: url, options: .completeFileProtectionUntilFirstUserAuthentication)
Prevent Duplicate Scheduling
BGTaskScheduler.shared.getPendingTaskRequests { requests in
if !requests.contains(where: { $0.identifier == "com.app.refresh" }) {
self.scheduleRefresh()
}
}
Related
- ax-energy -- Battery impact of background tasks, energy optimization
- ax-networking -- Background URLSession patterns, network conditions
- ax-media -- Background audio playback with AVAudioSession
- ax-privacy -- Permission UX for background refresh settings
Source
git clone https://github.com/Kasempiternal/axiom-v2/blob/main/axiom-plugin/skills/ax-background-tasks/SKILL.mdView on GitHub Overview
This skill covers iOS background execution patterns: BGTaskScheduler for refresh and maintenance, BGProcessingTask for longer tasks, BGContinuedProcessingTask for user-initiated work, background URLSession for durable downloads, and beginBackgroundTask for quick state saves. It provides practical Swift code patterns, decision guidance, and anti-patterns to help apps stay responsive.
How This Skill Works
Register task identifiers in didFinishLaunchingWithOptions, then schedule BGAppRefreshTask and BGProcessingTask requests as shown. Each task uses an expirationHandler and completes via setTaskCompleted, while background URLSession keeps downloads alive across termination; SwiftUI's .backgroundTask wrapper is also demonstrated.
When to Use It
- User explicitly initiates action with a progress UI (iOS 26+ BGContinuedProcessingTask; older may use beginBackgroundTask).
- Keep content fresh throughout the day with short tasks (BGAppRefreshTask, ~30s runtime).
- Deferrable maintenance like DB cleanup or ML training (BGProcessingTask with constraints such as external power and network).
- Large downloads or uploads that should survive termination (Background URLSession).
- Silent server-triggered data fetches (content-available:1) to refresh data.
Quick Start
- Step 1: Register the BGTaskScheduler task identifiers in application:willFinishLaunchingWithOptions: (before return).
- Step 2: Schedule an initial BGAppRefreshTaskRequest (and submit it) to start the cycle.
- Step 3: Implement the handler to perform work, schedule the next refresh, and call setTaskCompleted on all paths.
Best Practices
- Register in didFinishLaunchingWithOptions before returning from application:didFinishLaunchingWithOptions:.
- Set expirationHandler as the first line inside each task to ensure proper cancellation.
- Call setTaskCompleted on all code paths to avoid orphaned tasks.
- Schedule the next task to enable a continuous pattern (e.g., scheduleNextRefresh).
- Match the task type to the work duration and constraints (<=30s for BGAppRefreshTask; longer work for BGProcessingTask; use background URLSession for transfers).
Example Use Cases
- BGAppRefreshTask with identifier com.app.refresh to fetchData and schedule the next refresh.
- BGProcessingTaskRequest with requiresExternalPower and requiresNetworkConnectivity for maintenance tasks.
- BGContinuedProcessingTaskRequest for exporting photos with a progress UI.
- URLSession background configuration (com.app.dl) with sessionSendsLaunchEvents to survive termination.
- SwiftUI .backgroundTask(.appRefresh("com.app.refresh")) usage to trigger refresh when app is in the background.