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+.
BGHealthResearchTaskrequires iOS 17+. All code in this post targets iOS 17+ with Swift 6.2 strict concurrency.
Contents
- The Problem
- How BackgroundTasks Work
- BGAppRefreshTask: Lightweight Data Sync
- BGProcessingTask: Heavy Lifting
- BGHealthResearchTask: Health Data Studies
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
- You register task identifiers at app launch.
- You schedule a task request, specifying when you’d like it to run.
- The system decides when to actually launch it, balancing battery, thermal state, network conditions, and user patterns.
- Your handler runs in a time-limited window. You perform work and call
setTaskCompleted(success:)when done. - The system may expire your task early. You handle the
expirationHandlerby cancelling work and completing gracefully.
There are two primary task types:
| Task Type | Time Limit | Use Case | Power Required |
|---|---|---|---|
BGAppRefreshTask | ~30 seconds | Lightweight sync, fetching new data | No |
BGProcessingTask | Up to several minutes | Heavy computation, data migration, ML training | Optional (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:
BGHealthResearchTaskrequires thecom.apple.developer.healthkit.background-deliveryentitlement 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)
| Scenario | Recommendation |
|---|---|
| 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 downloads | Use URLSession background transfer — it has its own system-managed scheduling and survives app termination. |
| Continuous location tracking | Use CLLocationManager with allowsBackgroundLocationUpdates — separate mechanism from BackgroundTasks. |
| Playing audio in background | Use the audio background mode — not BackgroundTasks. |
| Health research data collection | BGHealthResearchTask (iOS 17+) with appropriate entitlements. |
| Widget timeline refresh | WidgetKit manages its own background refresh schedule. Use BackgroundTasks only if the widget data requires a network sync your app manages. |
Summary
BGAppRefreshTaskprovides ~30 seconds of execution time for lightweight sync operations. Schedule it when your app enters background.BGProcessingTaskprovides 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
expirationHandlerto cancel in-flight work gracefully. Use cooperativeTaskcancellation for structured concurrency compatibility. - Register handlers at every launch (
App.init()ordidFinishLaunchingWithOptions). Schedule tasks only when transitioning to background. - The system controls timing entirely. Treat
earliestBeginDateas 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.