Get the FREE Ultimate OpenClaw Setup Guide →

ax-background-tasks

npx machina-cli add skill Kasempiternal/axiom-v2/ax-background-tasks --openclaw
Files (1)
SKILL.md
16.7 KB

Background 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

TypeRuntimeWhen RunsInfo.plist Mode
BGAppRefreshTask~30sUser usage patternsfetch
BGProcessingTaskMinutesCharging, idle (overnight)processing
BGContinuedProcessingTaskExtendedUser-initiated (iOS 26+)processing
beginBackgroundTask~30sImmediately on backgroundNone
Background URLSessionAs neededSystem-optimal, survives terminationNone
Silent push~30sServer-triggeredremote-notification

The 7 Scheduling Factors (WWDC 2020-10063)

FactorImpact
Critically Low Battery (<20%)Discretionary work paused
Low Power ModeBackground activity limited
App UsageMore frequent = higher priority
App SwitcherSwiped away = no background
Background App Refresh settingOff = no BGAppRefresh
System BudgetsDeplete with launches, refill daily
Rate LimitingSystem 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

  1. Info.plist identifier matches code exactly (case-sensitive)
  2. UIBackgroundModes includes fetch and/or processing
  3. Registration in didFinishLaunchingWithOptions before return true
  4. App not swiped away from App Switcher
  5. Background App Refresh enabled in Settings
  6. Battery > 20%, Low Power Mode off
  7. LLDB _simulateLaunchForTaskWithIdentifier triggers handler

Task Terminates Early Checklist

  1. Expiration handler set as FIRST line in handler
  2. setTaskCompleted(success:) called in ALL code paths
  3. Work duration within task type limits (~30s refresh, minutes processing)
  4. Network operations use background URLSession for large transfers
  5. Expiration handler actually cancels in-progress work

Works in Dev, Not Production

  1. Debugger attached changes timing behavior
  2. Check ProcessInfo.isLowPowerModeEnabled
  3. Check UIApplication.backgroundRefreshStatus
  4. User may have force-quit from App Switcher
  5. Rarely-used apps get lower scheduling priority
  6. 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

  1. Step 1: Register the BGTaskScheduler task identifiers in application:willFinishLaunchingWithOptions: (before return).
  2. Step 2: Schedule an initial BGAppRefreshTaskRequest (and submit it) to start the cycle.
  3. 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.

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers