A user taps Export, watches the progress bar reach 18 percent, and switches to Messages. Before iOS 26, an app often had to choose between a short background grace period, a discretionary task that might start much later, or a workflow redesigned around system-owned file transfers. None precisely described “continue this substantial job I just started.”
BGContinuedProcessingTask fills that gap on iOS and iPadOS 26. It begins in response to an explicit foreground action, can keep using CPU and network after the app is backgrounded, reports progress through system UI, and lets the person cancel. That makes it a strong fit for exports, compression, media processing, and on-device analysis.
It is not unlimited runtime. The system can still terminate expensive work, and the person remains in control. A production implementation therefore needs truthful progress, cooperative cancellation, durable checkpoints, and idempotent recovery—not merely a request submission.
1. Start with the User-Initiated Contract
Apple’s definition is narrower than “long background work.” A continued-processing task must represent a clear goal that somebody starts with an action such as tapping a button or confirming a dialog. It should begin immediately when resources permit and make progress whose completion has an understandable meaning.
Good candidates include:
- exporting a video or project archive;
- generating thumbnails for photos the user just selected;
- compressing media before a social post;
- running an explicitly requested Core ML analysis; or
- applying a batch transformation to chosen documents.
Automatic backups, speculative prefetching, periodic synchronization, analytics uploads, and database housekeeping violate that mental model. Those jobs have no immediate user request for the system to explain in a Live Activity. Use a deferred API instead.
This consent boundary should also shape your interface. Show the cost before starting, make the destination clear, and avoid submitting the task from scenePhase changes, a timer, or a settings toggle. Submission must happen while the app is foregrounded and as a consequence of the current action.
Apple’s continued-processing documentation lists CPU-intensive image processing, network use, and supported background GPU work as valid workloads. The API is new in iOS 26 and iPadOS 26, so isolate it behind an availability boundary rather than spreading checks across the feature.
2. Configure and Identify the Task
Add an entry to BGTaskSchedulerPermittedIdentifiers in the app target’s Info.plist. Identifiers need your bundle identifier as a prefix:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.Studio.project-export</string>
</array>
Use a static identifier when only one export can be active. Continued-processing tasks also support permitted identifiers ending in .*, with a dynamic suffix in the identifier used for registration and submission. That can distinguish genuinely independent jobs, but it is not a reason to start an unbounded number of them. Each job consumes memory, CPU, and energy, and the system limits concurrency.
The request also requires a localized title and subtitle. These strings are system-facing product copy, not debug labels:
let request = BGContinuedProcessingTaskRequest(
identifier: "com.example.Studio.project-export",
title: String(localized: "Exporting project"),
subtitle: String(localized: "Preparing archive")
)
The title should state the result, while the subtitle should describe the current stage. A running task can update both with updateTitle(_:subtitle:). Never put a filename containing private information, an account identifier, or diagnostic detail into system-visible text.
If the task genuinely needs GPU execution in the background, add the Background GPU Access capability and check support at runtime before declaring the requirement:
if BGTaskScheduler.supportedResources.contains(.gpu) {
request.requiredResources = .gpu
}
Requesting an unavailable resource causes submission to fail. Do not request GPU access merely because a framework can use a GPU; first provide a CPU path or a clear unsupported-device outcome.
3. Register and Submit from the Foreground
Unlike a BGProcessingTask, which must be registered promptly during app launch because it can relaunch the app later, Apple allows a continued-processing handler to be registered when the user expresses intent. Register before submitting, and ensure your composition root and input state outlive the screen that owns the button.
Here is a complete shape for an export coordinator. ExportJobStore persists the input and checkpoint atomically; ProjectExporter performs bounded units of work and is safe to call on the dedicated worker queue.
@preconcurrency import BackgroundTasks
import Foundation
struct ExportJob: Codable, Sendable {
let id: UUID
let projectURL: URL
let destinationURL: URL
var nextChunk: Int
let chunkCount: Int
}
protocol ExportJobStore: Sendable {
func save(_ job: ExportJob) throws
func loadPendingJob() throws -> ExportJob?
func markFinished(id: UUID) throws
func recordFailure(id: UUID, message: String) throws
}
protocol ProjectExporter: Sendable {
func exportChunk(_ index: Int, for job: ExportJob) throws
}
final class CancellationSignal: @unchecked Sendable {
private let lock = NSLock()
private var cancelled = false
func cancel() {
lock.lock()
cancelled = true
lock.unlock()
}
var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return cancelled
}
}
@available(iOS 26.0, *)
final class ContinuedExportWorker: @unchecked Sendable {
static let identifier = "com.example.Studio.project-export"
private let store: any ExportJobStore
private let exporter: any ProjectExporter
init(store: any ExportJobStore, exporter: any ProjectExporter) {
self.store = store
self.exporter = exporter
}
func handle(_ task: BGTask) {
guard let task = task as? BGContinuedProcessingTask else {
task.setTaskCompleted(success: false)
return
}
let cancellation = CancellationSignal()
task.expirationHandler = { cancellation.cancel() }
do {
guard var job = try store.loadPendingJob(), job.chunkCount > 0 else {
task.setTaskCompleted(success: false)
return
}
task.progress.totalUnitCount = Int64(job.chunkCount)
task.progress.completedUnitCount = Int64(job.nextChunk)
while job.nextChunk < job.chunkCount {
guard !cancellation.isCancelled else {
try store.save(job)
task.setTaskCompleted(success: false)
return
}
try exporter.exportChunk(job.nextChunk, for: job)
job.nextChunk += 1
// Persist before publishing progress so the UI never gets ahead
// of the durable checkpoint.
try store.save(job)
task.progress.completedUnitCount = Int64(job.nextChunk)
// Cancellation can arrive while exportChunk is running. The
// completed chunk is now durable, so stop before more work.
guard !cancellation.isCancelled else {
task.setTaskCompleted(success: false)
return
}
if job.nextChunk == job.chunkCount / 2 {
task.updateTitle(
String(localized: "Exporting project"),
subtitle: String(localized: "Finalizing archive")
)
}
}
// Close the race between the final chunk and finalization.
guard !cancellation.isCancelled else {
try store.save(job)
task.setTaskCompleted(success: false)
return
}
try store.markFinished(id: job.id)
task.setTaskCompleted(success: true)
} catch {
if let job = try? store.loadPendingJob() {
try? store.recordFailure(id: job.id,
message: String(describing: error))
}
task.setTaskCompleted(success: false)
}
}
}
@MainActor
@available(iOS 26.0, *)
final class ContinuedExportController {
private let scheduler = BGTaskScheduler.shared
private let worker: ContinuedExportWorker
private let store: any ExportJobStore
private let queue = DispatchQueue(label: "com.example.Studio.export")
private var registered = false
init(store: any ExportJobStore, exporter: any ProjectExporter) {
self.store = store
self.worker = ContinuedExportWorker(store: store, exporter: exporter)
}
func start(_ job: ExportJob) throws {
if !registered {
registered = scheduler.register(
forTaskWithIdentifier: ContinuedExportWorker.identifier,
using: queue
) { [worker] task in
worker.handle(task)
}
guard registered else { throw ExportStartError.registrationFailed }
}
try store.save(job)
let request = BGContinuedProcessingTaskRequest(
identifier: ContinuedExportWorker.identifier,
title: String(localized: "Exporting project"),
subtitle: String(localized: "Preparing archive")
)
request.strategy = .fail
do {
try scheduler.submit(request)
} catch {
try? store.recordFailure(id: job.id,
message: String(describing: error))
throw error
}
}
}
enum ExportStartError: Error {
case registrationFailed
}
There is one deliberately boring design decision here: the launch handler does not depend on a view model. Navigation can destroy the view while the export continues. The handler instead resolves durable job state through services owned by the application composition root. If you split that code into a Swift package, the same dependency direction used in SPM modularization for scalable iOS codebases keeps BackgroundTasks at the app boundary.
In production, avoid persisting raw error strings if they might contain paths or user data. Store a bounded error category and keep detailed diagnostics in your privacy-reviewed logging pipeline.
4. Choose .fail or .queue Deliberately
The default strategy is .queue. If capacity is unavailable, the system puts the request at the back of a queue and starts it as soon as possible. This works when the user can leave a prepared export waiting and your durable input remains valid.
Use .fail when delayed execution would violate the interaction: perhaps a connected accessory must remain in its current mode, or the source is ephemeral. Submission then throws if the system cannot begin promptly, giving your UI an immediate opportunity to retain the project, offer a foreground-only path, or ask the person to retry.
Do not silently fall back from .fail to .queue; those choices have different user expectations. Also remember that the system cancels queued continued-processing requests if the person closes the app from the app switcher. A queue is not durable job infrastructure.
Handle every submission error. Keep the screen in a recoverable state, make duplicate taps idempotent, and never display “Exporting” until submission actually succeeds.
5. Treat Progress as Part of Correctness
BGContinuedProcessingTask conforms to ProgressReporting. The system uses its Progress object both to show the Live Activity and to reason about tasks that appear stuck. Under resource pressure, work showing little or no progress is more likely to be terminated.
Progress must represent completed durable work—not loop iterations, bytes merely read into memory, or an animation timer. Break a pipeline into weighted stages if their cost differs substantially:
let overall = task.progress
overall.totalUnitCount = 1_000
let render = Progress(totalUnitCount: 100)
let encode = Progress(totalUnitCount: 100)
let package = Progress(totalUnitCount: 100)
overall.addChild(render, withPendingUnitCount: 600)
overall.addChild(encode, withPendingUnitCount: 300)
overall.addChild(package, withPendingUnitCount: 100)
Update at meaningful intervals. Per-byte updates add synchronization overhead, while one update after ten minutes looks stalled. For most batch workloads, an update after each independently recoverable item or chunk is a useful starting point.
Keep the foreground UI driven by the same domain progress, but do not create two competing owners. A durable job record can feed the app screen, while task.progress feeds system UI. This also prevents a common mistake: doing heavy encoding on MainActor just because a SwiftUI progress bar observes it. The diagnosis techniques in why async Swift code can still freeze the UI apply equally here.
6. Make Expiration and Cancellation Cheap
The expiration handler is not a second work queue. It is a signal to stop. It can run because the person canceled through system UI or because conditions changed and iOS reclaimed resources.
Set it before beginning expensive work. Its body should flip cancellation state or cancel an operation, then return. The worker must check that signal at bounded intervals, close resources, persist the latest valid checkpoint, and call setTaskCompleted(success: false).
Avoid synchronous database compaction, network cleanup, or a final full-file rewrite inside the expiration handler. If a single library call can block for minutes without cancellation, the surrounding loop is not cooperatively cancellable. Choose a chunked API, configure cancellation support, or isolate the operation so the app can abandon partial output safely.
Call setTaskCompleted(success:) exactly once on every path. true means the requested result is ready, not merely that the handler returned without throwing. User cancellation, corrupted input, expired credentials, and an incomplete checkpoint are failures even when cleanup succeeds.
7. Design Failure Recovery Before the Happy Path
Background execution is opportunistic. Thermal pressure, memory pressure, shutdown, a crash, or force-quitting can end your process without a graceful callback. Therefore, the expiration handler cannot be your only recovery mechanism.
A robust export uses these rules:
- Write output to a job-specific temporary directory.
- Persist the next unit of work with an atomic file replacement or transaction.
- Make each unit idempotent, so replaying it produces the same result.
- Validate the final artifact before moving it to its public destination.
- Clean stale temporary artifacts during a later foreground launch or discretionary maintenance task.
If work stops, show a concrete status on next launch: Resume export, Start again, or Discard partial export. Do not automatically submit another continued-processing request; a new request still requires a current explicit action.
Instrumentation should distinguish submission rejection, task expiration or cancellation, processing failure, and successful recovery. BGContinuedProcessingTask delivers both a person’s cancellation and a system stop through expirationHandler; it exposes no public termination-reason value. Record them as one outcome unless your app has separate, reliable evidence. Aggregate outcomes by app and OS version. For production regressions and termination evidence, a privacy-conscious MetricKit monitoring pipeline provides a better signal than scattering personal job details through analytics.
8. Pick the Background API by Workload
The APIs are complements, not generations of the same mechanism.
| API | Starts when | Best fit | Important constraint |
|---|---|---|---|
BGContinuedProcessingTask | From an explicit foreground action, immediately if possible | User-visible exports, transforms, and analysis lasting minutes or more | iOS/iPadOS 26; measurable progress and cancellation |
BGProcessingTask | Later, when the system chooses | Maintenance, indexing, model training, deferred synchronization | No exact start time; advertise network and power needs |
BGAppRefreshTask | Later, opportunistically | Short freshness updates before likely app use | Brief runtime; not heavy computation |
Background URLSession | Transfers are scheduled and owned by the system | Large HTTP uploads and downloads | File-transfer semantics, not arbitrary app computation |
Use background URLSession when the remaining job is a transfer. It can continue while your app is suspended or terminated and relaunch your app to process events. Do not burn continued-processing runtime waiting for a large upload that the networking daemon can own.
A multi-stage workflow can combine mechanisms. Use BGContinuedProcessingTask to render and package an explicitly requested video, then hand the completed file to a background upload task. Store a state transition between the two so a process death cannot upload an incomplete artifact.
Use BGProcessingTask for later cleanup of abandoned export directories, not for the original user-visible export. Use BGAppRefreshTask to refresh a small manifest, not to encode its media. And reserve beginBackgroundTask(withName:expirationHandler:) for a short, finite cleanup such as closing a file or committing state—not as a substitute for a long-running task.
9. Test the Unhappy Paths on Real Hardware
First test the ordinary path on an iOS 26 device: submit from a visible button, background the app, confirm the system UI displays localized text and believable progress, then return to the app and verify priority and UI state recover cleanly.
Next exercise the cases that expose architectural bugs:
- cancel from the system interface during every major stage;
- force a submission failure and confirm
.failleaves usable input; - background under Low Power Mode and adverse thermal conditions;
- terminate and relaunch between checkpoint and progress publication;
- remove the app from the app switcher while a
.queuerequest waits; - fill storage during output finalization;
- remove network access between local processing and upload; and
- submit twice to prove the job identifier and destination are idempotent.
Apple documents debugger commands for launching and terminating scheduled background tasks during development, but debugger simulation does not reproduce scheduling policy, energy pressure, or the system UI faithfully. Treat it as a handler test, then validate on physical devices without relying on a debugger-attached process.
Unit-test the exporter separately with injected cancellation after every chunk. Test the job store with process-like reopen cycles: save, discard all objects, recreate the store, and resume. A task that only works while an in-memory coordinator survives is not recoverable background work.
10. Availability and Deployment Strategy
BGContinuedProcessingTask requires iOS 26 or iPadOS 26 and an SDK that exposes the API. Wrap types that mention it in @available, then make one product decision for older systems:
@MainActor
func startExport(_ job: ExportJob) throws {
if #available(iOS 26.0, *) {
try continuedController.start(job)
} else {
// Keep the export in the foreground, or present an explicit
// “keep this screen open” experience. Do not promise long runtime.
try foregroundExporter.start(job)
}
}
For a short final save, older releases may use beginBackgroundTask, but it supplies limited grace time and is not a faithful fallback for a ten-minute encode. If background completion is central to the product, consider making iOS 26 the minimum for that feature while keeping a foreground path elsewhere.
Recheck the API against the final SDK and deployment release notes you ship with. Background behavior is system policy as well as source compatibility; passing compilation does not prove that a workload is energy-efficient or eligible.
11. Conclusion
The most useful way to think about BGContinuedProcessingTask is not “more background time.” It is a contract among the person, your app, and the system. The person asks for a concrete result, your app reports honest progress and saves recoverable state, and iOS balances that work against device conditions while retaining the right to stop it.
When your architecture honors that contract, the API removes a frustrating interruption from substantial exports and transformations. When it does not, no submission strategy can make a fragile in-memory loop reliable.
Key Takeaways
- Submit only after an explicit foreground action for a clear, user-visible result.
- Register the handler before submission and keep its dependencies outside view lifetimes.
- Use
.failfor must-start-now work and.queueonly when delayed execution remains meaningful. - Report durable, measurable progress and stop promptly when expiration is signaled.
- Checkpoint idempotently because process termination can bypass cleanup handlers.
- Prefer background
URLSessionfor system-owned transfers and deferred BG tasks for automatic work. - Test cancellation, queuing, storage pressure, relaunch, and availability on real devices.