BackgroundTasks Framework: Running Code When Your App Isn't Active


Your Pixar film tracker app needs to sync the user’s watchlist overnight so they see fresh data in the morning widget. Your media app needs to transcode imported video while the phone charges. Your health app needs to process sensor data every few hours. None of these can wait for the user to open the app. The BackgroundTasks framework gives you scheduled execution time when your app is suspended or terminated — with clear contracts about what the system expects in return.

This post covers BGAppRefreshTask, BGProcessingTask, and BGHealthResearchTask, including registration, scheduling, expiration handling, and debugging. We won’t cover background URLSession transfers or push notification triggers — those are separate mechanisms. This guide assumes familiarity with app lifecycle and async/await.

Note: The BackgroundTasks framework requires iOS 13+. BGHealthResearchTask requires iOS 17+. All code in this post targets iOS 17+ with Swift 6.2 strict concurrency.

Contents

The Problem

Consider a Pixar film tracker that syncs watchlist data with CloudKit. Without background execution, the sync only happens when the user opens the app. If they haven’t opened it in three days, the app’s widget shows stale data, notifications reference outdated state, and the first launch requires a full sync that blocks the UI:

// Without background tasks — sync only happens at launch
@MainActor
final class FilmSyncManager {
    func applicationDidBecomeActive() async {
        // User opens the app after 3 days
        // Full sync blocks UI for several seconds
        do {
            let changes = try await cloudKitService.fetchAllChanges()
            try await localStore.apply(changes) // Potentially thousands of records
        } catch {
            // User sees a spinner, gets frustrated, closes app
        }
    }
}

This is a poor experience. The app should have synced incrementally in the background — fetching a few changes every few hours — so that launch is instant and the widget always reflects the latest state. The BackgroundTasks framework solves this by letting the system wake your app at opportune moments to perform work.

How BackgroundTasks Work

Apple Docs: BackgroundTasks — BackgroundTasks Framework

The framework operates on a simple contract:

  1. You register task identifiers at app launch.
  2. You schedule a task request, specifying when you’d like it to run.
  3. The system decides when to actually launch it, balancing battery, thermal state, network conditions, and user patterns.
  4. Your handler runs in a time-limited window. You perform work and call setTaskCompleted(success:) when done.
  5. The system may expire your task early. You handle the expirationHandler by cancelling work and completing gracefully.

There are two primary task types:

Task TypeTime LimitUse CasePower Required
BGAppRefreshTask~30 secondsLightweight sync, fetching new dataNo
BGProcessingTaskUp to several minutesHeavy computation, data migration, ML trainingOptional (configurable)

The system uses on-device heuristics to schedule tasks. An app the user opens every morning at 8 AM might get its refresh task scheduled around 7:30 AM. An app opened once a month may rarely get background execution time. You cannot control when the task runs — only request it and let the system optimize.

Registration and Entitlements

Before any scheduling, you need two things:

1. Add the Background Modes capability in your Xcode project under Signing & Capabilities. Check “Background fetch” for BGAppRefreshTask and “Background processing” for BGProcessingTask.

2. Register permitted task identifiers in Info.plist under the BGTaskSchedulerPermittedIdentifiers key:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>io.cocoabytes.filmtracker.refresh</string>
    <string>io.cocoabytes.filmtracker.processing</string>
</array>

Warning: If you forget to add the identifier to BGTaskSchedulerPermittedIdentifiers, the task registration will silently succeed but the task will never execute. This is the most common source of “my background task never runs” bugs.

BGAppRefreshTask: Lightweight Data Sync

Apple Docs: BGAppRefreshTask — BackgroundTasks

BGAppRefreshTask is designed for brief network calls — fetching new content, checking for updates, syncing a small delta. The system gives you roughly 30 seconds of wall-clock time.

Step 1: Register the Handler

Register your handler in application(_:didFinishLaunchingWithOptions:) for UIKit, or in your App initializer for SwiftUI:

import BackgroundTasks

@main
struct PixarFilmTrackerApp: App {
    private static let refreshTaskID = "io.cocoabytes.filmtracker.refresh"

    init() {
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: Self.refreshTaskID,
            using: nil // nil = main queue
        ) { task in
            guard let refreshTask = task as? BGAppRefreshTask else { return }
            Task {
                await Self.handleAppRefresh(refreshTask)
            }
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }

    @MainActor
    static func handleAppRefresh(_ task: BGAppRefreshTask) async {
        // Schedule the next refresh before doing work
        scheduleAppRefresh()

        let syncManager = FilmSyncManager.shared

        // Handle expiration — cancel work if the system reclaims time
        task.expirationHandler = {
            syncManager.cancelSync()
        }

        do {
            let success = try await syncManager.performIncrementalSync()
            task.setTaskCompleted(success: success)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    static func scheduleAppRefresh() {
        let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
        request.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 60 * 60) // 2 hours from now

        do {
            try BGTaskScheduler.shared.submit(request)
        } catch {
            // Log but don't crash — scheduling can fail in Simulator or restricted environments
            print("Failed to schedule refresh: \(error)")
        }
    }
}

A few critical details. First, you must call setTaskCompleted(success:) — failure to do so will cause the system to penalize your app’s future scheduling priority. Second, always set the expirationHandler to cancel in-flight work. The system calls it on a background queue moments before killing your task. Third, schedule the next refresh at the beginning of your handler, not the end — if your task crashes or is expired, the next one is already queued.

Step 2: Schedule on Entering Background

The best time to schedule a refresh is when the user backgrounds the app:

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        FilmListView()
            .onChange(of: scenePhase) { _, newPhase in
                if newPhase == .background {
                    PixarFilmTrackerApp.scheduleAppRefresh()
                }
            }
    }
}

earliestBeginDate is a floor, not a guarantee. Setting it to 2 hours from now means the system will not run the task before 2 hours, but may run it 4, 8, or even 24 hours later depending on device state and user patterns.

BGProcessingTask: Heavy Lifting

Apple Docs: BGProcessingTask — BackgroundTasks

BGProcessingTask is for work that takes minutes, not seconds — database migrations, image processing, ML model updates, or large data exports. The system grants significantly more time, especially when the device is charging and connected to Wi-Fi.

extension PixarFilmTrackerApp {
    private static let processingTaskID = "io.cocoabytes.filmtracker.processing"

    static func registerProcessingTask() {
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: processingTaskID,
            using: nil
        ) { task in
            guard let processingTask = task as? BGProcessingTask else { return }
            Task {
                await handleProcessing(processingTask)
            }
        }
    }

    @MainActor
    static func handleProcessing(_ task: BGProcessingTask) async {
        let processor = FilmDataProcessor.shared

        task.expirationHandler = {
            processor.cancelProcessing()
        }

        do {
            // Heavy work: generate thumbnails for all films, rebuild search index
            try await processor.rebuildSearchIndex()
            try await processor.generateMissingThumbnails()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    static func scheduleProcessing() {
        let request = BGProcessingTaskRequest(identifier: processingTaskID)
        request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 60 * 60)
        request.requiresNetworkConnectivity = true
        request.requiresExternalPower = true // Only run while charging

        do {
            try BGTaskScheduler.shared.submit(request)
        } catch {
            print("Failed to schedule processing: \(error)")
        }
    }
}

The requiresExternalPower and requiresNetworkConnectivity properties are hints that help the system schedule your task at optimal moments. When requiresExternalPower is true, the system waits until the device is plugged in — which typically means overnight, when the user is sleeping and the device is on a charger. This is the ideal window for heavy computation.

Cancellable Work with async/await

The expirationHandler runs on a different queue than your task handler. In Swift concurrency, use cooperative cancellation through Task:

@MainActor
static func handleProcessing(_ task: BGProcessingTask) async {
    let workTask = Task {
        let processor = FilmDataProcessor.shared
        // Check for cancellation between units of work
        for film in processor.filmsNeedingThumbnails {
            try Task.checkCancellation()
            try await processor.generateThumbnail(for: film)
        }
    }

    task.expirationHandler = {
        workTask.cancel() // Triggers Task.checkCancellation() on the next iteration
    }

    do {
        try await workTask.value
        task.setTaskCompleted(success: true)
    } catch is CancellationError {
        // Expired — partial work is saved, we'll resume next time
        task.setTaskCompleted(success: false)
    } catch {
        task.setTaskCompleted(success: false)
    }
}

This pattern — a Task that checks cancellation between iterations, cancelled from the expirationHandler — is the cleanest way to handle expiration with structured concurrency.

BGHealthResearchTask: Health Data Studies

Apple Docs: BGHealthResearchTask — BackgroundTasks

Introduced in iOS 17, BGHealthResearchTask is a specialized task type for apps participating in health research studies. It gets elevated scheduling priority and longer execution time because health data collection has regulatory requirements around completeness and timeliness.

// Simplified for clarity — health research apps require specific entitlements
let request = BGHealthResearchTaskRequest(identifier: "io.cocoabytes.healthstudy.collection")
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)
request.protectionTypeOfRequiredData = .completeUntilFirstUserAuthentication

do {
    try BGTaskScheduler.shared.submit(request)
} catch {
    print("Failed to schedule health research task: \(error)")
}

Warning: BGHealthResearchTask requires the com.apple.developer.healthkit.background-delivery entitlement and your app must be enrolled in an active health research study through Apple. This is not a general-purpose task type — abuse will result in App Store rejection.

Advanced Usage

Debugging Background Tasks

Background tasks are notoriously difficult to debug because the system controls when they run. Xcode provides two LLDB commands to trigger them manually:

# Simulate launching a background task (from LLDB while app is running)
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"io.cocoabytes.filmtracker.refresh"]

# Simulate expiration of a running task
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"io.cocoabytes.filmtracker.refresh"]

To use these: run your app on a real device, background it, pause execution in Xcode, and paste the LLDB command. The task handler will fire immediately.

Tip: On iOS 16.4+, you can also trigger background tasks from Settings > Developer > Background Tasks Debugger. This is more reliable than LLDB for complex multi-task scenarios.

Coordinating Multiple Tasks

When you have both a refresh and a processing task, coordinate them through shared state to avoid redundant work:

@Observable @MainActor
final class SyncCoordinator {
    private(set) var lastRefreshDate: Date?
    private(set) var lastProcessingDate: Date?
    private(set) var pendingChangeCount: Int = 0

    func performIncrementalSync() async throws -> Bool {
        let changes = try await cloudKitService.fetchChanges(since: lastRefreshDate)

        guard !changes.isEmpty else {
            lastRefreshDate = .now
            return true
        }

        try await localStore.apply(changes)
        pendingChangeCount += changes.count
        lastRefreshDate = .now
        return true
    }

    func processAccumulatedChanges() async throws {
        guard pendingChangeCount > 0 else { return }

        try await searchIndexer.rebuildIndex()
        try await thumbnailGenerator.generateMissing()
        pendingChangeCount = 0
        lastProcessingDate = .now
    }
}

The refresh task syncs small deltas frequently. The processing task handles accumulated heavy work less often. This separation respects the system’s time budgets — a 30-second refresh window for fetching data, a multi-minute processing window for crunching it.

Handling App Termination

The system may terminate your app between task registration and execution. This is fine — registered tasks persist across app launches. However, you must re-register your handlers every launch. The schedule survives termination; the handler closure does not.

// Registration must happen every launch — in didFinishLaunchingWithOptions or App.init()
// Scheduling only needs to happen once (the request persists until fulfilled)
init() {
    Self.registerRefreshTask()
    Self.registerProcessingTask()
    // Do NOT re-schedule here — only schedule when entering background
    // Scheduling on every launch can reset the system's timing heuristics
}

Warning: Do not call BGTaskScheduler.shared.submit() on every app launch. This resets the scheduling timer and can delay your task execution. Only schedule when transitioning to background or when no pending request exists.

Checking Pending Tasks

You can inspect whether a request is already pending to avoid duplicate submissions:

static func scheduleAppRefreshIfNeeded() {
    BGTaskScheduler.shared.getPendingTaskRequests { requests in
        let hasPendingRefresh = requests.contains {
            $0.identifier == refreshTaskID
        }

        if !hasPendingRefresh {
            scheduleAppRefresh()
        }
    }
}

Performance Considerations

The system tracks your background task performance and adjusts future scheduling accordingly:

Completion rate matters. If your task consistently calls setTaskCompleted(success: false) or gets expired before completing, the system reduces your scheduling frequency. Keep tasks focused and fast — a 5-second refresh is better than a 25-second one that risks expiration.

Battery impact is monitored. The system uses the MetricKit framework to track your background CPU and network usage. Excessive background battery drain leads to reduced scheduling. Profile your background work with Instruments’ Energy Log template.

Memory limits apply. Background tasks run with a lower memory ceiling than foreground execution. On devices with 4GB RAM, expect a ~50MB limit for refresh tasks. Exceeding it causes an immediate jetsam kill with no expiration handler callback.

Network efficiency. Batch your network calls. A single request fetching 50 changes is far better than 50 individual requests. Each network connection has fixed overhead (DNS resolution, TLS handshake) that compounds in a time-limited window.

// Efficient: single batched request
func performIncrementalSync() async throws -> Bool {
    let changes = try await api.fetchChanges(since: lastSyncToken, limit: 100)
    try await localStore.batchApply(changes)
    return true
}

// Inefficient: N individual requests
func performIncrementalSync() async throws -> Bool {
    let ids = try await api.fetchChangedIDs()
    for id in ids {
        let record = try await api.fetchRecord(id) // Separate request per record
        try await localStore.save(record)
    }
    return true
}

Apple Docs: BGTaskScheduler — BackgroundTasks

When to Use (and When Not To)

ScenarioRecommendation
Periodic data sync (every few hours)BGAppRefreshTask — the primary use case.
Heavy computation (ML, indexing, export)BGProcessingTask with requiresExternalPower: true.
Time-sensitive updates (messaging, live scores)Use push notifications instead — background tasks have no timing guarantees.
Large file downloadsUse URLSession background transfer — it has its own system-managed scheduling and survives app termination.
Continuous location trackingUse CLLocationManager with allowsBackgroundLocationUpdates — separate mechanism from BackgroundTasks.
Playing audio in backgroundUse the audio background mode — not BackgroundTasks.
Health research data collectionBGHealthResearchTask (iOS 17+) with appropriate entitlements.
Widget timeline refreshWidgetKit manages its own background refresh schedule. Use BackgroundTasks only if the widget data requires a network sync your app manages.

Summary

  • BGAppRefreshTask provides ~30 seconds of execution time for lightweight sync operations. Schedule it when your app enters background.
  • BGProcessingTask provides minutes of execution time for heavy computation, optionally requiring power and network connectivity.
  • Always call setTaskCompleted(success:) — failure to do so degrades your app’s future scheduling priority.
  • Set expirationHandler to cancel in-flight work gracefully. Use cooperative Task cancellation for structured concurrency compatibility.
  • Register handlers at every launch (App.init() or didFinishLaunchingWithOptions). Schedule tasks only when transitioning to background.
  • The system controls timing entirely. Treat earliestBeginDate as a floor, not a target.
  • Debug with LLDB’s _simulateLaunchForTaskWithIdentifier: command or Settings > Developer > Background Tasks Debugger.

Background tasks pair naturally with CloudKit sync for keeping data fresh across devices. If your sync is triggered by remote changes rather than a timer, explore push notifications for server-initiated wake-ups.