Background Task Patterns — Extended Reference
Overflow reference for the background-processing skill. Contains debugging tips, advanced background URLSession patterns, background push best practices, and SwiftUI integration patterns.
Contents
- Debugging Background Tasks
- Advanced BGProcessingTask Patterns
- Background URLSession — Extended Patterns
- Background Push — Extended Patterns
- SwiftUI BackgroundTask Modifier
- BGContinuedProcessingTask — Extended Patterns
Debugging Background Tasks
Simulating Task Launches in Xcode
Use the LLDB console to trigger tasks instantly during development. The app must be running on a device in the debugger with a breakpoint hit or paused.
These are Apple-documented private functions for development only. Do not include references to them in App Store-submitted code.
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.app.refresh"]For processing tasks:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.app.db-cleanup"]To simulate early termination (expiration):
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.example.app.refresh"]Verifying Pending Tasks
Check what tasks are currently scheduled:
BGTaskScheduler.shared.getPendingTaskRequests { requests in
for request in requests {
print("Pending: \(request.identifier), earliest: \(String(describing: request.earliestBeginDate))")
}
}Common Debugging Issues
| Symptom | Cause | Fix |
|---|---|---|
| Task never fires | Identifier not in Info.plist | Add to BGTaskSchedulerPermittedIdentifiers |
| Task never fires | Background modes not enabled | Enable fetch and/or processing in capabilities |
| Task never fires on device | Background App Refresh disabled or Low Power Mode active | Check UIApplication.shared.backgroundRefreshStatus; Low Power Mode reduces background runtime |
.notPermitted error |
Identifier mismatch | Verify exact string match between code and plist |
.unavailable error |
Running in extension | BGTaskScheduler not available in app extensions |
.tooManyPendingTaskRequests |
More than 1 refresh task or 10 processing tasks scheduled in total | Cancel old requests before submitting new ones |
Submitting an unexecuted task request with the same identifier replaces the previous request.
Advanced BGProcessingTask Patterns
Conditional Requirements
Use requiresExternalPower and requiresNetworkConnectivity to ensure the
system only launches your task when conditions are met:
func scheduleSyncTask() {
let request = BGProcessingTaskRequest(
identifier: "com.example.app.full-sync"
)
// Only run when charging and connected to network
request.requiresExternalPower = true
request.requiresNetworkConnectivity = true
// Don't run before 2 AM
var components = DateComponents()
components.hour = 2
if let twoAM = Calendar.current.nextDate(
after: Date(),
matching: components,
matchingPolicy: .nextTime
) {
request.earliestBeginDate = twoAM
}
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule sync: \(error)")
}
}Incremental Work with Checkpointing
Design tasks to save progress so they can resume if terminated:
func handleMigration(task: BGProcessingTask) {
let work = Task {
let lastProcessed = UserDefaults.standard.integer(
forKey: "migrationLastIndex"
)
let items = try await loadItems()
for (index, item) in items.dropFirst(lastProcessed).enumerated() {
try Task.checkCancellation()
try await migrate(item)
// Checkpoint progress
UserDefaults.standard.set(
lastProcessed + index + 1,
forKey: "migrationLastIndex"
)
}
task.setTaskCompleted(success: true)
}
task.expirationHandler = {
work.cancel()
// Progress is saved -- next launch picks up where we left off
task.setTaskCompleted(success: false)
}
}Background URLSession — Extended Patterns
Configuration Best Practices
let config = URLSessionConfiguration.background(
withIdentifier: "com.example.app.background-transfer"
)
// isDiscretionary = true: system picks optimal time (WiFi, power)
// Use for non-urgent transfers
config.isDiscretionary = true
// sessionSendsLaunchEvents = true: app relaunched when transfer completes
config.sessionSendsLaunchEvents = true
// Set reasonable timeouts
config.timeoutIntervalForResource = 60 * 60 * 24 * 7 // 7 days
// Allow cellular (default is true)
config.allowsCellularAccess = trueUpload with Background Session
func uploadFile(at fileURL: URL) {
var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
request.httpMethod = "POST"
let uploadTask = session.uploadTask(with: request, fromFile: fileURL)
uploadTask.resume()
}Important: Background sessions only support uploadTask(with:fromFile:) and
downloadTask(with:). Data tasks, uploadTask(with:from:) (Data), and
closure/async-based tasks are not supported.
Handling App Relaunch
When the system completes a background transfer and your app is not running, it relaunches the app. You must:
- Store the completion handler from
application(_:handleEventsForBackgroundURLSession:completionHandler:) - Recreate the
URLSessionwith the same identifier - Call the stored completion handler in
urlSessionDidFinishEvents
// AppDelegate
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
// Recreating session with the same identifier reconnects to the transfer
_ = DownloadManager.shared.session // trigger lazy init
DownloadManager.shared.completionHandler = completionHandler
}
// In your URLSessionDelegate
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
Task { @MainActor in
self.completionHandler?()
self.completionHandler = nil
}
}Handling Download Errors and Retries
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: (any Error)?
) {
guard let error else { return } // Success handled in didFinishDownloadingTo
let nsError = error as NSError
// Check if download can be resumed
if let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
// Store resumeData and retry later
let downloadTask = session.downloadTask(withResumeData: resumeData)
downloadTask.resume()
return
}
// Non-resumable error -- retry from scratch or notify user
if nsError.code == NSURLErrorNetworkConnectionLost {
// Re-enqueue the download
if let url = task.originalRequest?.url {
let newTask = session.downloadTask(with: url)
newTask.resume()
}
}
}Progress Tracking
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
// Update UI on main actor if app is in foreground
Task { @MainActor in
DownloadProgressStore.shared.update(
taskID: downloadTask.taskIdentifier,
progress: progress
)
}
}Background Push — Extended Patterns
Push Payload Requirements
The content-available: 1 flag is required. You can include custom data:
{
"aps": {
"content-available": 1
},
"type": "new-message",
"conversation-id": "abc-123"
}Do not include alert, badge, or sound if you only want a silent push.
Including visual notification keys changes the push behavior.
Send background notification requests with APNs headers:
apns-push-type: background
apns-priority: 5Rate Limiting
Apple throttles background push delivery. Guidelines:
- Delivery is low priority and not guaranteed.
- Do not send more than two or three background notifications per hour.
- The system may hold only the newest background notification and discard older held notifications.
- If the user force-quits the app, background pushes stop until the next manual launch.
- Use
apns-priority: 5; high-priority pushes are for user-visible notifications, not silent refresh.
Handling Push with Async Work
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler:
@escaping (UIBackgroundFetchResult) -> Void
) {
guard let type = userInfo["type"] as? String else {
completionHandler(.noData)
return
}
Task {
do {
switch type {
case "new-message":
let conversationID = userInfo["conversation-id"] as? String
let fetched = try await MessageService.shared
.fetchMessages(for: conversationID)
completionHandler(fetched ? .newData : .noData)
case "config-update":
try await ConfigService.shared.refreshConfig()
completionHandler(.newData)
default:
completionHandler(.noData)
}
} catch {
completionHandler(.failed)
}
}
}Important: You have approximately 30 seconds to call completionHandler.
Failure to do so causes the system to penalize your app's background push
budget.
SwiftUI BackgroundTask Modifier
SwiftUI provides a .backgroundTask modifier as an alternative to manual
BGTaskScheduler registration:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup { ContentView() }
.backgroundTask(.appRefresh("com.example.app.refresh")) {
await refreshFeed()
// Schedule the next one
scheduleAppRefresh()
}
}
}This is a SwiftUI handler for matching background tasks. You still need the Info.plist identifiers, background modes, scheduling, and cancellation-safe work patterns.
BGContinuedProcessingTask — Extended Patterns
Checking Supported Resources
Before requesting GPU or other resources, verify the device supports them and
enable Background GPU Access
(com.apple.developer.background-tasks.continued-processing.gpu) for GPU work:
let supported = BGTaskScheduler.supportedResources
if supported.contains(.gpu) {
request.requiredResources = .gpu
}Submission Strategies
BGContinuedProcessingTaskRequest.SubmissionStrategy controls behavior when the
system cannot run the task immediately:
| Strategy | Behavior |
|---|---|
.queue |
Task is queued and starts as soon as possible |
.fail |
Submission fails immediately if can't run now |
Use .fail when the work is only relevant in the current moment (e.g., a user
is waiting). Use .queue for work that can start whenever the system allows.
Cancellation by the User
The system shows a Live Activity for continued processing tasks. The user can cancel the task from there. Handle this in your expiration handler:
task.expirationHandler = {
// Clean up partial work
cleanupPartialExport()
task.setTaskCompleted(success: false)
}Progress Reporting
The system uses your Progress object to decide termination priority. Tasks
with no progress updates are terminated first under resource pressure:
// Report fine-grained progress
let progress = task.progress
progress.totalUnitCount = Int64(totalItems)
for (index, item) in items.enumerated() {
try Task.checkCancellation()
await process(item)
progress.completedUnitCount = Int64(index + 1)
}