iOS Background Tasks in Production: Refresh, Processing, and Continued Work
- Author
- Ehsan Azish · 3NSOFTS
- Updated
- September 2026
- Read time
- 15 min read
- Level
- Intermediate
- Platform
- iOS 17+, Swift concurrency, BackgroundTasks framework
Implementation Notes
- ~/ What broke: Background work is scheduled opportunistically, so timer-like assumptions create stale data and fragile recovery paths.
- ~/ What to do: Choose the task lifecycle honestly, keep work cancelable and resumable, and test expiration independently from scheduling.
iOS background tasks have always been a source of confusion. In 2026, the stakes are higher. With on-device AI inference, local-first sync, and privacy-sensitive data pipelines now standard in production apps, getting background execution right is no longer something you can defer to a later sprint. This guide covers how BGAppRefreshTask and BGProcessingTask work today, what has shifted in recent iOS releases, and where developers most often go wrong when building apps that need to do real work while the screen is off.
Why Background Tasks Still Trip Up Experienced Developers
The BackgroundTasks framework shipped in iOS 13, but the mental model many developers carry around it is outdated. Apple has tightened scheduling heuristics, added new task types, and adjusted how the system weighs battery, thermal state, and user behavior when deciding whether to grant background runtime.
If your app syncs a local database, pre-fetches a Core ML model, or runs a lightweight inference pass before the user opens the app, you are depending on this framework whether you have thought carefully about it or not. Getting the details wrong means users see stale data, failed syncs, or AI features that feel sluggish because the warm-up work never happened.
The Two Core Task Types
BGAppRefreshTask
BGAppRefreshTask is designed for short, opportunistic work. The system decides when to run it based on conditions such as app usage, battery state, and network availability. Treat the available runtime as limited and variable rather than building against a promised number of seconds.
Registration looks like this:
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
handleAppRefresh(task: task as! BGAppRefreshTask)
}
The identifier must appear in your Info.plist under BGTaskSchedulerPermittedIdentifiers. A missing entry is a common reason registration or submission fails during development.
Inside the handler, you must call task.setTaskCompleted(success:) before the system terminates your process. Skip it, and iOS marks the task as failed and deprioritizes future scheduling for that identifier.
func handleAppRefresh(task: BGAppRefreshTask) {
scheduleNextRefresh() // always reschedule first
let operation = SyncOperation()
task.expirationHandler = {
operation.cancel()
}
operation.completionBlock = {
task.setTaskCompleted(success: !operation.isCancelled)
}
let queue = OperationQueue()
queue.qualityOfService = .utility
queue.addOperation(operation)
}
For recurring refresh, submit the next request before beginning fallible asynchronous work. If no future work is needed, do not resubmit merely to keep a chain alive.
BGProcessingTask
BGProcessingTask handles heavier work: database migrations, model downloads, Core ML compilation, or anything that needs more than 30 seconds. The system typically runs these overnight or when the device is plugged in and idle.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.processing",
using: nil
) { task in
handleProcessingTask(task: task as! BGProcessingTask)
}
When submitting a BGProcessingTaskRequest, two properties significantly affect scheduling:
let request = BGProcessingTaskRequest(identifier: "com.yourapp.processing")
request.requiresNetworkConnectivity = false
request.requiresExternalPower = true
submitTaskRequest(request)
Setting requiresExternalPower = true tells the system that the task may wait for external power. Use it only when the work is genuinely deferrable and energy intensive. It narrows the circumstances in which the request is eligible, so it is a workload requirement rather than a trick for improving scheduling odds.
Account for thermal and power conditions
iOS owns the scheduling decision and can defer or end work as conditions change. If your task performs expensive inference or indexing, checking ProcessInfo.processInfo.thermalState lets the app voluntarily postpone nonessential work when the device is already under thermal pressure.
func handleAppRefresh(task: BGAppRefreshTask) {
scheduleNextRefresh()
let thermal = ProcessInfo.processInfo.thermalState
guard thermal != .serious && thermal != .critical else {
task.setTaskCompleted(success: false)
return
}
// proceed with work
}
This is an application-level safeguard, not a guarantee about how the system scheduler will behave.
Continue a person-initiated operation
BGContinuedProcessingTask serves a different lifecycle from scheduled refresh and processing tasks. The app submits it from the foreground in direct response to a person's action, such as starting a video export or processing a selected batch of images. The work can then continue if the person backgrounds the app.
This API is available on iOS and iPadOS 26 or later. Keep a foreground or resumable fallback when supporting earlier systems.
Create a BGContinuedProcessingTaskRequest with a localized title and subtitle. The system presents progress in a Live Activity, and the person can cancel the task. Report meaningful progress continuously and implement the expiration handler because the system can still terminate the operation under resource pressure. Use the request's .queue strategy when delayed start is acceptable or .fail when the operation should fail rather than wait for capacity.
Do not use continued processing as a replacement for recurring maintenance, speculative model warm-up, or silent synchronization. Those jobs do not begin with an explicit foreground action and belong in BGProcessingTask, BGAppRefreshTask, push-driven work, or normal foreground execution as appropriate.
Common Mistakes That Kill Background Execution
Not Submitting the Next Request Immediately
Submit the next BGTaskRequest at the very start of your handler, before any async work begins. If the task is cancelled or expires, the next scheduling cycle is already registered.
Using DispatchQueue.main for Heavy Work
The queue used for a launch handler depends on how the task was registered. Do not assume expensive work is automatically off the main thread. Move CPU-heavy work to an appropriate task, actor, or operation queue while keeping UI state isolated to the main actor.
Ignoring the Expiration Handler
The expirationHandler closure tells your work to stop and gives it a chance to leave durable state consistent. Cancel operations promptly and design database work around short, atomic transactions. Do not begin lengthy cleanup inside the expiration handler; the system is already reclaiming execution time.
Over-requesting Background Time
Register only the identifiers the app genuinely needs and submit a request only when work is pending. Multiple identifiers create more lifecycle and recovery paths to test, while none of them makes execution on a particular schedule guaranteed.
Background Tasks and On-Device AI
This is where the 2026 context matters most. Apps using Core ML or Apple Foundation Models for on-device inference increasingly want to do preparatory work in the background: warming a model, pre-computing embeddings, or running a lightweight inference pass on new data before the user opens the app.
BGProcessingTask with requiresExternalPower = true is the right vehicle for model compilation and embedding pre-computation. These are expensive but not time-sensitive, making them a natural fit for overnight or charging-state execution.
BGAppRefreshTask can suit small, cancelable preparatory work, such as updating lightweight metadata. Measure on supported hardware, keep the unit of work bounded, and save progress incrementally because the system may expire it.
Core ML model compilation can be expensive on older hardware. Scheduled, deferrable compilation is a better fit for BGProcessingTask. Use BGContinuedProcessingTask only when compilation belongs to an operation the person explicitly started in the foreground and whose visible progress can continue in the background.
If on-device AI is a core feature of your app rather than an enhancement, the architecture decisions around background execution compound quickly. The Swift 6 AI Integration Guide covers how to structure these pipelines so that background inference work integrates cleanly with your main app's data layer without introducing concurrency bugs under Swift 6's strict actor isolation rules.
Scheduling Strategy for Local-First Apps
Apps built on local-first architecture, where Core Data or SwiftData is the source of truth and CloudKit sync runs in the background, have a specific set of background task needs.
A practical architecture separates the responsibilities:
- One
BGAppRefreshTaskidentifier for lightweight sync checks: verify pending uploads, pull remote change tokens, update local metadata. - One
BGProcessingTaskidentifier for heavier reconciliation: conflict resolution, bulk import, index rebuilding. - CloudKit's native push notifications for real-time sync when the app is in the foreground or recently backgrounded.
Do not try to drive all sync through background tasks alone. Push notifications can signal that remote data changed, while background task requests provide opportunities for deferred maintenance and reconciliation. Neither mechanism promises immediate execution, so foreground refresh and resumable local state remain necessary.
The DevScope Swift 6 Performance case study shows how Swift 6 concurrency changes affect background data pipelines specifically, including how actor isolation interacts with CloudKit callbacks.
Testing Background Tasks Reliably
The Xcode 26 simulator improvements help, but gaps between simulator behavior and device behavior remain. A few practices that close that gap:
Use a physical device for final validation. The simulator does not replicate the system's real scheduling heuristics. A task that fires reliably in the simulator may be deprioritized on device because the app's usage history is short.
Inspect device logs. Console can provide useful scheduling and expiration evidence around your task identifier, although system logging is implementation detail and may change between OS releases.
Set a breakpoint on BGTaskScheduler.shared.submit and verify no error is thrown. The submit call throws if the identifier is not registered in Info.plist or if you call it outside the app lifecycle. Catching this at the point of submission is faster than debugging a task that silently never fires.
Test expiration handling deliberately. Exercise cancellation and expiration paths during development and verify that work stops promptly, partial output remains valid, and the next launch can resume safely. Treat device testing as the final proof because simulated launches do not reproduce scheduling policy.
When Background Tasks Surface in Architecture Reviews
Background task implementation is a useful architecture-review target because incorrect identifier registration, missing expiration handling, and sync pipelines that assume timely execution create user-visible reliability risk.
The AI-Native App Architecture Audit findings from 2026 covers the most common issues found across iOS codebases, including background execution patterns that create reliability problems at scale.
Frequently Asked Questions
What is the difference between BGAppRefreshTask and BGProcessingTask?
BGAppRefreshTask is for short, opportunistic refresh work. BGProcessingTask is for time-consuming, deferrable processing and lets a request declare network or external-power requirements. In both cases, the system chooses when to launch the task and may end it, so neither is a timer or guaranteed scheduler.
Why does my background task never fire on a real device?
The most common causes: the task identifier is missing from BGTaskSchedulerPermittedIdentifiers in Info.plist, the next task request is not being submitted inside the handler so the chain breaks after the first run, or the app's usage history is too short for the system to prioritize it. Check dasd logs in Console.app for the specific deferral reason.
Can I run Core ML inference inside a BGAppRefreshTask?
Potentially, if the inference is small, cancelable, and useful even when scheduled opportunistically. Profile the exact model and supported devices. Use BGProcessingTask for heavier deferrable batches, and require external power only when that is an honest requirement of the workload.
What is BGContinuedProcessingTask and when should I use it? It is for a long-running operation that a person starts while the app is in the foreground and expects to continue after leaving the app. The system exposes its progress in a Live Activity and allows cancellation. It is not a scheduler for silent recurring maintenance.
How should a task respond to elevated thermal state?
For nonessential, expensive work, inspect ProcessInfo.processInfo.thermalState and consider postponing the operation when the state is .serious or .critical. Always make the operation resumable and still implement expiration handling; the thermal check does not replace system lifecycle callbacks.
How do I test background tasks without waiting for the system to schedule them?
Use development-time task simulation where available, unit-test the underlying operation independently of BGTask, and deliberately exercise cancellation and recovery. Then validate on a physical device; simulated execution proves handler behavior, not that production scheduling will occur at a chosen time.
Should I use background tasks or CloudKit push notifications for sync? Use both, for different purposes. CloudKit's silent push notifications are better for real-time sync when the app has been recently used. Background tasks fill the gap for scheduled sync work, conflict resolution, and operations that need to run on a cadence rather than in response to a push event.
Make every operation resumable
Background task implementation looks straightforward until an app ships and users start reporting stale data, failed syncs, or features that feel unprepared. Reliable implementations assume execution is opportunistic, keep work cancelable and resumable, and use continued processing only for visible operations a person actually initiated.
Primary references
- Background Tasks framework
- Performing long-running tasks on iOS and iPadOS
- BGAppRefreshTask
- BGProcessingTask
The durable rule is simple: background execution is an opportunity, not a deadline. Persist enough state to resume, make cancellation safe, and keep foreground refresh capable of repairing anything the scheduler did not run.