Keep a User-Started Export Alive with BGContinuedProcessingTask
Some work starts because a person tapped a button and reasonably expects it to finish: exporting a video, rendering a large PDF, preparing a ZIP file, or processing a batch of photos.
For years, iOS developers had to choose between imperfect tools for that kind of work. BGProcessingTask is for deferrable maintenance at a system-chosen time. URLSession background transfers are excellent for network transfer, but they do not cover the CPU work before or after the upload. UIApplication.beginBackgroundTask can buy a little time, but it is intentionally limited.
BGContinuedProcessingTask, introduced with iOS and iPadOS 26, fills a more specific space: visible, user-initiated work that your foreground app asks the system to begin immediately or shortly after submission, and that may need to keep running if the app moves to the background.
The important words are visible and user-initiated. This is not a private daemon mode for arbitrary background work. The system shows progress to the user, the task can expire, and your code must report progress while it runs.
The Xcode 26.5 headers mark this API as available on iOS 26 and unavailable on macOS, tvOS, visionOS, watchOS, and Mac Catalyst. If your app supports older OS versions or Catalyst, gate the feature with availability checks and provide another path.
Pick the right job
A good continued-processing job has these traits:
- It begins from an explicit user action.
- The user can understand the title and progress.
- The work is important enough to continue if the app is backgrounded.
- The work can be cancelled or failed cleanly.
- The app can report progress with
Progress.
An export is a good example. In this post, imagine a SwiftUI app that renders a project into a movie file and then lets a background URLSession upload that file.
The continued-processing task should cover the render/export work. If the upload can be handed to a background URLSession, let the system transfer service own that part after you have produced the file.
Create a task identifier
Apple’s docs for BGContinuedProcessingTaskRequest call out a wildcard-style identifier. The prefix should include your bundle identifier, and each submitted task should use a unique suffix:
enum ExportTaskID {
static let base = "com.example.VideoLab.export"
static func make() -> String {
"\(base).\(UUID().uuidString)"
}
}
In BGTaskSchedulerPermittedIdentifiers, include the wildcard form:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.VideoLab.export.*</string>
</array>
You should also configure the relevant Background Modes capability for the app target and test on device. The simulator can be useful for API-level checks, but it is not a reliable place to validate background scheduling behavior.
Submit the request from the foreground
Create the request when the user starts the export. The title and subtitle are user-facing strings that the system can show while the task is running.
import BackgroundTasks
enum ExportSchedulingError: Error {
case registrationFailed
}
@MainActor
final class ExportCoordinator {
private var activeExports: [String: ExportJob] = [:]
private var registeredIdentifiers: Set<String> = []
func startExport(project: Project) async throws {
let identifier = ExportTaskID.make()
let request = BGContinuedProcessingTaskRequest(
identifier: identifier,
title: "Exporting Video",
subtitle: "Preparing frames"
)
request.strategy = .queue
if BGTaskScheduler.supportedResources.contains(.gpu) {
request.requiredResources = .gpu
}
let job = ExportJob(project: project)
activeExports[identifier] = job
guard registerHandler(for: identifier) else {
activeExports[identifier] = nil
throw ExportSchedulingError.registrationFailed
}
do {
try BGTaskScheduler.shared.submit(request)
} catch {
activeExports[identifier] = nil
throw error
}
}
}
There are two submission strategies:
.queuelets the system queue the request if it cannot run immediately..failthrows if the task is not eligible to run right away.
I usually start with .queue for user exports because it avoids turning temporary system load into an immediate app error. Use .fail when starting later would make the work confusing or stale. Apple’s headers also note that queued continued-processing requests are cancelled if the user removes your app from the app switcher, so persist enough job state to recover or explain what happened.
Request .gpu only when the job truly needs background GPU access, and only if the device supports it. Apple’s headers note that this also requires the background GPU entitlement.
Register the handler
BGTaskScheduler gives the launch handler a BGTask. For this API, cast it to BGContinuedProcessingTask, connect it to the app work, and always set an expiration handler.
Persist the mapping from task identifier to export job metadata before submission. The launch handler may run after your process has been killed, so it needs enough durable state to reconstruct the work instead of relying only on in-memory dictionaries. Also verify the identifier shape against the SDK you ship with; some continued-processing flows use a concrete submitted identifier, while others use a registered wildcard-style prefix.
import BackgroundTasks
extension ExportCoordinator {
private func registerHandler(for identifier: String) -> Bool {
guard !registeredIdentifiers.contains(identifier) else {
return true
}
let registered = BGTaskScheduler.shared.register(
forTaskWithIdentifier: identifier,
using: nil
) { [weak self] task in
guard
let self,
let task = task as? BGContinuedProcessingTask
else { return }
Task {
await self.runExport(identifier: identifier, backgroundTask: task)
}
}
if registered {
registeredIdentifiers.insert(identifier)
}
return registered
}
}
Registering the same identifier twice is an error, and Apple’s headers say the system kills the app on a second registration of the same identifier. Real apps need bookkeeping around this. The example keeps an in-memory table to focus on the task shape; production code should also persist enough metadata to reconnect the visible task with the underlying export work if the app lifecycle changes.
Report progress while work runs
BGContinuedProcessingTask conforms to ProgressReporting. The task’s progress is not decoration. Apple’s headers say continued-processing tasks must report progress, and tasks that appear stalled may be expired.
extension ExportCoordinator {
private func runExport(
identifier: String,
backgroundTask: BGContinuedProcessingTask
) async {
guard let job = activeExports[identifier] else {
backgroundTask.setTaskCompleted(success: false)
return
}
backgroundTask.progress.totalUnitCount = 100
backgroundTask.expirationHandler = { [job] in
Task {
await job.cancel()
}
}
do {
let movieURL = try await job.render { completed, total in
let percent = total == 0 ? 0 : Int((Double(completed) / Double(total)) * 100)
backgroundTask.progress.completedUnitCount = Int64(percent)
backgroundTask.updateTitle(
"Exporting Video",
subtitle: "\(percent)% complete"
)
}
try await enqueueUpload(for: movieURL)
backgroundTask.setTaskCompleted(success: true)
} catch is CancellationError {
backgroundTask.setTaskCompleted(success: false)
} catch {
backgroundTask.setTaskCompleted(success: false)
}
activeExports[identifier] = nil
}
}
There are three details worth copying into real code.
First, set expirationHandler. If the system or user cancels the task, your work should stop quickly.
Second, call setTaskCompleted(success:) exactly once when the job reaches a terminal state. If you forget, the system can keep the app alive until its time expires and may kill the app.
Third, update progress as the work advances. If your export has multiple phases, use child Progress values or map each phase into a predictable range so the visible progress does not sit at zero for half the job.
Pair it with background URLSession
A continued-processing task is useful for the local work the app must do now. A background URLSession is still the right owner for a long upload.
func enqueueUpload(for movieURL: URL) async throws {
let configuration = URLSessionConfiguration.background(
withIdentifier: "com.example.VideoLab.uploads"
)
let session = URLSession(configuration: configuration)
var request = URLRequest(url: URL(string: "https://example.com/uploads")!)
request.httpMethod = "POST"
let task = session.uploadTask(with: request, fromFile: movieURL)
task.resume()
}
That is only the enqueueing side of a background transfer. In a production app, create or reuse a background session with a delegate, and implement the app or scene lifecycle handoff that lets the system wake your app for transfer events.
The division of labor is simple:
BGContinuedProcessingTaskkeeps the user-started export alive while you produce the file.- Background
URLSessionhandles the transfer after the file exists.
That keeps each system API in the part of the workflow it was designed for.
A practical checklist
Before shipping a continued-processing feature, walk through this list:
- The task starts from a user action, not a hidden timer.
- The feature is guarded for iOS/iPadOS 26 availability.
- The identifier includes your bundle identifier and a unique suffix.
- The wildcard identifier is listed in
BGTaskSchedulerPermittedIdentifiers. - The app handles registration and submission failures.
- The task title and subtitle make sense outside your app UI.
- The task reports
Progressthroughout the run. expirationHandlercancels work quickly.- Every path calls
setTaskCompleted(success:). - GPU resources are requested only when needed and supported.
- Long uploads are handed to background
URLSession. - Behavior is tested on real devices with background refresh settings in mind.
BGContinuedProcessingTask is not magic background time. It is more interesting than that: a public contract between your app, the system, and the person who started the work. If your app keeps that contract visible, cancellable, and honest, it finally has a good home for exports that should not disappear the moment someone locks the screen.
Further reading:
- Performing long-running tasks on iOS and iPadOS: https://developer.apple.com/documentation/backgroundtasks/performing-long-running-tasks-on-ios-and-ipados
BGContinuedProcessingTask: https://developer.apple.com/documentation/backgroundtasks/bgcontinuedprocessingtaskBGContinuedProcessingTaskRequest: https://developer.apple.com/documentation/backgroundtasks/bgcontinuedprocessingtaskrequestBGTaskScheduler: https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler