Before iOS 26, the BackgroundTasks framework gave you two request types: a short app refresh task for fetching data, and a longer processing task for heavy work that could wait until the device was idle. iOS 26 adds a user-initiated continuation task. Here's how they stack up:
| Task type | Duration | Trigger | Typical use case |
BGAppRefreshTask | ~30 seconds | System-scheduled, opportunistic | Quick data fetches, feed refreshes, badge updates |
BGProcessingTask | Minutes, while device is idle | System-scheduled, requires idle device | Database migrations, ML model updates, large file processing |
BGContinuedProcessingTask | Until completion or system stop | User-initiated in the foreground, continues in background | Video exports, large uploads, archive compression |
The mental shift in iOS 26 is that BGContinuedProcessingTask isn't opportunistic at all. The user explicitly taps a button (export, upload, compress), and the system commits to letting that work finish, displaying progress UI even after the app backgrounds. You aren't begging the scheduler for time anymore — you're handing off in-flight work.
Honestly, that's the part I've been waiting on for years.
One-time project setup
None of the code below runs without two pieces of project configuration. Get these wrong and your task handlers will silently never fire (ask me how I know).
1. Enable the Background Modes capability
In Xcode, select your target, open Signing & Capabilities, click + Capability, and add Background Modes. Check the modes that match the task types you plan to use:
- Background fetch — required for
BGAppRefreshTask.
- Background processing — required for
BGProcessingTask and BGContinuedProcessingTask.
2. Declare permitted identifiers in Info.plist
iOS will reject any task identifier that isn't pre-declared. Add the BGTaskSchedulerPermittedIdentifiers array to your Info.plist (Xcode 16+ shows this under the target's Info tab, or you can edit the source file directly):
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.swiftcrafted.example.refresh</string>
<string>com.swiftcrafted.example.cleanup</string>
<string>com.swiftcrafted.example.export</string>
</array>
Use reverse-DNS identifiers, and keep them stable. They survive across launches and are how the system routes a scheduled task back to your handler.
BGAppRefreshTask: short, frequent updates
BGAppRefreshTask gives you roughly 30 seconds of CPU time, usually while the device is plugged in or otherwise convenient for iOS to wake your app. It's perfect for keeping a feed warm, refreshing tokens, or updating a widget's timeline.
SwiftUI integration with the .backgroundTask modifier
SwiftUI exposes a backgroundTask(_:action:) scene modifier that registers and handles the task in one place — no AppDelegate required. This is the idiomatic iOS 26 pattern:
import SwiftUI
import BackgroundTasks
@main
struct FeedApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
scheduleAppRefresh()
}
}
.backgroundTask(.appRefresh("com.swiftcrafted.example.refresh")) {
await refreshFeed()
}
}
private func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(
identifier: "com.swiftcrafted.example.refresh"
)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try? BGTaskScheduler.shared.submit(request)
}
private func refreshFeed() async {
do {
let articles = try await FeedClient.shared.latest()
try await ArticleStore.shared.save(articles)
} catch {
// Surface the error to your logging/telemetry pipeline.
}
}
}
Why submit every time the app backgrounds
BGTaskScheduler holds at most one pending request per identifier. Submitting again from scenePhase == .background is the canonical pattern: it refreshes the earliestBeginDate, recovers from a missed previous submission, and costs essentially nothing if a request is already queued.
One thing to keep in mind: earliestBeginDate is a floor, not a deadline. The system may wait hours longer based on usage patterns, battery, and thermal state. Don't treat it as a promise.
BGProcessingTask: long, opportunistic work
BGProcessingTask trades immediacy for runtime. iOS only runs it while the device is idle (typically overnight, on charger), but gives your code minutes instead of seconds. The moment the user picks up the phone, iOS will signal expiration — and you must stop within a few seconds.
import BackgroundTasks
func scheduleNightlyCleanup() {
let request = BGProcessingTaskRequest(
identifier: "com.swiftcrafted.example.cleanup"
)
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true
request.earliestBeginDate = nextRunAfterMidnight()
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Cleanup schedule failed: \(error)")
}
}
private func nextRunAfterMidnight() -> Date {
let calendar = Calendar.current
let tomorrow = calendar.date(byAdding: .day, value: 1, to: Date())!
return calendar.startOfDay(for: tomorrow)
}
Wire the handler with the SwiftUI scene modifier:
.backgroundTask(.appRefresh("com.swiftcrafted.example.cleanup")) {
await CleanupService.shared.run()
}
Inside the handler, you must cooperate with the Task cancellation protocol. When the system signals expiration, SwiftUI cancels the surrounding Task, and your async work should bail out gracefully:
actor CleanupService {
static let shared = CleanupService()
func run() async {
for batch in await pendingBatches() {
if Task.isCancelled { break }
await purge(batch)
}
}
}
BGContinuedProcessingTask: the iOS 26 game-changer
This is the API that genuinely changes how you ship features like video export or large uploads. The flow looks like this:
- The user taps an explicit action in your UI (e.g. "Export 4K video").
- Your code starts the work in the foreground and submits a
BGContinuedProcessingTaskRequest.
- The user switches apps or locks the device. iOS keeps your task alive, showing a system progress UI that the user can tap to return to your app.
- You report progress to the system; when the task finishes, the UI auto-dismisses.
Submitting a continued processing request
import BackgroundTasks
func startExport(for asset: VideoAsset) {
let request = BGContinuedProcessingTaskRequest(
identifier: "com.swiftcrafted.example.export",
title: "Exporting \(asset.name)",
subtitle: "4K \u{2022} H.265"
)
request.strategy = .fail // or .queue to wait if too many are running
do {
try BGTaskScheduler.shared.submit(request)
Task { await ExportEngine.shared.run(asset) }
} catch BGTaskScheduler.Error.tooManyPendingTaskRequests {
showAlert("Too many exports already in progress.")
} catch {
showAlert("Could not start export: \(error.localizedDescription)")
}
}
The title and subtitle strings show up in the system progress UI, so localize them like any other user-facing string. The strategy property is new in iOS 26: .fail rejects the request when the per-app limit is hit, while .queue waits its turn.
Handling the task and reporting progress
.backgroundTask(.continuedProcessing("com.swiftcrafted.example.export")) { task in
let progress = Progress(totalUnitCount: 100)
task.progress = progress
await ExportEngine.shared.export(
onProgress: { fraction in
progress.completedUnitCount = Int64(fraction * 100)
}
)
}
Two things worth pointing out:
- The closure receives the
BGContinuedProcessingTask instance, so you can assign a Progress object that drives the visible system UI.
- You don't need a separate
setTaskCompleted(success:) call — when the async closure returns, the task completes. That's it.
If the system needs to terminate your task early (thermal pressure, system shutdown, whatever), the closure's surrounding Task is cancelled. Always check Task.isCancelled inside long loops, and persist enough state to resume on the next launch.
Coordinating with URLSession background uploads
For pure upload/download work, a URLSession background configuration is still the right tool. It survives termination and is handled by nsurlsessiond, not your process. SwiftUI ties it together with another backgroundTask variant:
.backgroundTask(.urlSession("com.swiftcrafted.example.upload")) {
await UploadCoordinator.shared.handleEvents(for: "com.swiftcrafted.example.upload")
}
Pair this with a URLSessionConfiguration.background(withIdentifier:) session that uses isDiscretionary = true for non-urgent transfers, and let iOS pick an optimal moment (Wi-Fi, power, off-peak).
Battery transparency in iOS 26
iOS 26 surfaces per-app background battery usage in Settings > Battery, and users can disable background activity for individual apps from that screen. The practical implication? Avoid scheduling speculative work. Tasks that fail, run hot, or do nothing useful are now visible to users — and they will turn you off.
Two habits keep you in good standing:
- Always set
earliestBeginDate based on actual data freshness needs, not "as soon as possible."
- Profile your handler with Instruments' Time Profiler on a real device. Background CPU time counts double against battery perception because it's invisible to the user.
Testing background tasks (the only way that works)
Two unavoidable facts:
- The simulator does not run scheduled background tasks. You must test on a physical device.
- The system makes no guarantees about when your task will run. Waiting for "natural" execution can take days.
Thankfully, Apple ships two private LLDB helpers for development use:
# Force a scheduled task to launch right now
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.swiftcrafted.example.refresh"]
# Simulate the system expiring your task mid-flight
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.swiftcrafted.example.refresh"]
The typical workflow:
- Run on a real device attached to Xcode.
- Background the app (press the home indicator).
- Pause the debugger and paste the simulate-launch command in the LLDB console.
- Resume — your handler runs immediately.
You can also inspect the scheduler queue at any point:
Task {
let pending = await BGTaskScheduler.shared.pendingTaskRequests()
for request in pending {
print(request.identifier, request.earliestBeginDate ?? "no floor")
}
}
Common failure modes
- "No identifier registered" crash on launch. The task identifier in your code doesn't match
BGTaskSchedulerPermittedIdentifiers. Identifiers are case-sensitive and must match exactly — one stray uppercase letter and you're done.
- Task never runs in production. Often caused by users who force-quit the app. iOS treats force-quit as "the user doesn't want this app to do anything" and stops scheduling. There's no fix; document it and design around it.
- Handler times out at 30 seconds. You used
BGAppRefreshTask for work that needed BGProcessingTask. The fix is structural: split the workload or switch task types.
- Expiration cancels mid-write. Wrap any database or file-system commits in a transaction, and check
Task.isCancelled before opening it. Don't start writes you can't finish in a few seconds.
FAQ
What's the difference between BGAppRefreshTask and BGProcessingTask?
BGAppRefreshTask is short (around 30 seconds), runs frequently, and isn't gated on device idleness — it's for keeping data fresh. BGProcessingTask can run for minutes, but only when the device is idle, and you can require external power or network connectivity. Use the first for quick fetches, the second for heavy or maintenance work.
Why isn't my background task running on the simulator?
The simulator doesn't support BGTaskScheduler execution. You have to run on a real device. During development, use the _simulateLaunchForTaskWithIdentifier: LLDB command while attached to Xcode to force the task to run on demand.
Can BGContinuedProcessingTask start without user interaction?
No. BGContinuedProcessingTask is explicitly user-initiated — it must be submitted in direct response to a user action (a tap, a swipe). The system surfaces UI to the user about the running task, so silent or speculative use just isn't allowed.
How often does iOS actually run BGAppRefreshTask?
There's no guarantee. iOS schedules refreshes based on how often the user opens your app, battery state, time of day, network reachability, and thermal conditions. Apps used multiple times a day may see several refreshes per day; rarely-used apps may see one a week — or none if the user force-quit recently.
Does the new BGContinuedProcessingTask work on iPadOS and macOS?
Yes on iPadOS 26 — the API was introduced in parallel for both platforms. On macOS the same task type isn't needed, because Mac apps already run unrestricted in the background; use a regular Task and let the user quit the app to stop it.
Should I use BackgroundTasks or URLSession background uploads for large transfers?
For pure file transfer, prefer a URLSessionConfiguration.background session — it runs in nsurlsessiond and survives even if your app is terminated. Use BGContinuedProcessingTask when the work involves CPU processing alongside the transfer (encoding, encryption, compression) that needs your app's code to run.
Wrapping up
The iOS 26 BackgroundTasks framework finally has the right shape for the three real-world problems iOS apps face: keep data fresh, run heavy work overnight, and finish what the user started. The SwiftUI .backgroundTask scene modifier ties them all together without an AppDelegate, and the new BGContinuedProcessingTask is the first time iOS has given third-party developers a first-class story for foreground-to-background continuation.
Set up your identifiers, pick the right task type, cooperate with cancellation, and test on a real device with the LLDB helpers. The system will do the rest — most of the time.