---
name: background-processing
description: "iOS background work with BGTaskScheduler: BGAppRefreshTask short fetches, BGProcessingTask long maintenance, BGContinuedProcessingTask (iOS 26+) for user-started work, background URLSession transfers, silent pushes, Info.plist identifiers and modes, handler registration, expiration and completion, simulated launches for debugging. Use when adding, reviewing or debugging background work. Not for visible notification UI."
metadata:
  source: multi-agent-pipeline
---

# Background Processing

iOS gives an app background time only on its own terms. You describe the work,
the system chooses the moment, or skips it entirely, and your code has to finish
or stop cleanly whenever it is told to. This skill covers the BackgroundTasks
framework, background `URLSession` transfers and silent pushes.

Covers: background `URLSession` downloads and uploads. Not for designing
visible notification UI.

## Configuration

**Permitted identifiers.** Every task identifier goes into the Info.plist
array `BGTaskSchedulerPermittedIdentifiers`. A missing entry makes
`submit(_:)` throw `BGTaskScheduler.Error.Code.notPermitted`. An entry may end
in a wildcard, such as `org.sample.photos.export.*`, for per-job identifiers.

**Background modes.** `UIBackgroundModes` needs `fetch` for app refresh tasks
and `processing` for processing tasks. In Xcode: target, Signing &
Capabilities, Background Modes, then tick "Background fetch" and "Background
processing".

```xml
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>org.sample.photos.refresh</string>
    <string>org.sample.photos.cleanup</string>
    <string>org.sample.photos.export.*</string>
</array>
<key>UIBackgroundModes</key>
<array>
    <string>fetch</string>
    <string>processing</string>
</array>
```

## Registering handlers

Refresh and processing handlers must be registered before the app finishes
launching: in `application(_:didFinishLaunchingWithOptions:)` for UIKit, or in
the `App` initializer for SwiftUI. Registering later, for instance from a
view's `.task`, is too late for the system to deliver a launch. Continued
processing tasks are the one exception, covered below. Register each
identifier once: a second registration of the same identifier kills the app.

```swift
import BackgroundTasks
import SwiftUI

enum TaskID {
    static let refresh = "org.sample.photos.refresh"
    static let cleanup = "org.sample.photos.cleanup"
}

@main
struct PhotosApp: App {
    init() {
        BGTaskScheduler.shared.register(forTaskWithIdentifier: TaskID.refresh, using: nil) { task in
            guard let refresh = task as? BGAppRefreshTask else { return }
            LibrarySync.shared.handleRefresh(refresh)
        }
        BGTaskScheduler.shared.register(forTaskWithIdentifier: TaskID.cleanup, using: nil) { task in
            guard let cleanup = task as? BGProcessingTask else { return }
            LibrarySync.shared.handleCleanup(cleanup)
        }
    }

    var body: some Scene {
        WindowGroup {
            LibraryView()
        }
    }
}
```

Passing `nil` for `using:` runs the handler on a default background queue.

## App refresh tasks

A refresh task gets roughly 30 seconds for a small update. The system picks
the moment based on how the user uses the app. `earliestBeginDate` only says
"not before"; the task may run much later or not at all, and reviews should
treat it that way.

```swift
final class LibrarySync: Sendable {
    static let shared = LibrarySync()

    func scheduleRefresh() {
        let request = BGAppRefreshTaskRequest(identifier: TaskID.refresh)
        request.earliestBeginDate = .now.addingTimeInterval(15 * 60)
        if (try? BGTaskScheduler.shared.submit(request)) == nil {
            Log.background.error("Refresh request was rejected")
        }
    }

    func handleRefresh(_ task: BGAppRefreshTask) {
        scheduleRefresh()
        perform(task) { try await self.fetchNewAlbums() }
    }
}

func perform(_ task: BGTask, _ job: @escaping @Sendable () async throws -> Void) {
    nonisolated(unsafe) let task = task
    let work = Task {
        let finished = (try? await job()) != nil
        task.setTaskCompleted(success: finished && !Task.isCancelled)
    }
    task.expirationHandler = { work.cancel() }
}
```

`Log.background` stands for an `os.Logger` owned by the app, and the
processing example below reuses `perform`. Two habits matter: schedule the
next run first, so a crash or expiry does not break the chain, and always
install an `expirationHandler`, because the system can take the time back at
any moment. The handler only cancels; the work `Task` is the one completion
path, so `setTaskCompleted(success:)` runs exactly once, with `false` after an
expiry. That relies on the job honouring cancellation (`URLSession` async calls
and `Task.checkCancellation()` throw), so the `Task` ends soon after the
handler fires.

`BGTask` is not `Sendable`, yet both the work `Task` and the expiration
handler need it, and the handler runs on a queue the framework picks. The
`nonisolated(unsafe)` local copy marks that sharing as deliberate; without it
Swift 6 rejects the `Task` closure with "passing closure as a 'sending'
parameter risks causing data races".

## Processing tasks

Processing tasks can run for minutes and suit maintenance, indexing and
cleanup. They start only while the device is idle, and optionally only on
power.

```swift
func scheduleCleanup() {
    let request = BGProcessingTaskRequest(identifier: TaskID.cleanup)
    request.requiresNetworkConnectivity = false
    request.requiresExternalPower = true
    request.earliestBeginDate = .now.addingTimeInterval(3600)
    try? BGTaskScheduler.shared.submit(request)
}

func handleCleanup(_ task: BGProcessingTask) {
    scheduleCleanup()
    perform(task) { try await purgeOrphanedThumbnails() }
}
```

## Continued processing tasks (iOS 26+)

`BGContinuedProcessingTask` needs iOS or iPadOS 26.0 and is for work the user
starts on purpose, such as an export, that should keep going after they leave
the app. It differs from the other two types:

- It starts right away from the foreground instead of waiting for the system.
- The system shows its progress to the user as a Live Activity; the task
  adopts `ProgressReporting`.
- Under resource pressure the system may end it, and tasks reporting little
  progress go first.
- The user or the system can cancel it; the `expirationHandler` must stop the
  work and remove partial output, and the cancelled work then completes once.
- Its handler is exempt from the register-at-launch rule. `submit` expects
  an identifier that already has a handler, so register the job's identifier
  right before submitting it.

```swift
func startExport(of album: Album) {
    let identifier = "org.sample.photos.export.\(UUID().uuidString)"
    BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
        guard let export = task as? BGContinuedProcessingTask else { return }
        handleExport(export)
    }
    let request = BGContinuedProcessingTaskRequest(
        identifier: identifier,
        title: "Exporting \(album.name)",
        subtitle: "Preparing"
    )
    request.strategy = .fail
    if BGTaskScheduler.supportedResources.contains(.gpu) {
        request.requiredResources = .gpu
    }
    guard (try? BGTaskScheduler.shared.submit(request)) != nil else {
        showExportUnavailable()
        return
    }
}

func handleExport(_ task: BGContinuedProcessingTask) {
    nonisolated(unsafe) let task = task
    let work = Task { await runExport(task, items: ExportQueue.shared.pending) }
    task.expirationHandler = {
        work.cancel()
        ExportQueue.shared.removePartialFiles()
    }
}

func runExport(_ task: BGContinuedProcessingTask, items: [PhotoAsset]) async {
    task.progress.totalUnitCount = Int64(items.count)
    var done: Int64 = 0
    for asset in items where !Task.isCancelled {
        await render(asset)
        done += 1
        task.progress.completedUnitCount = done
        task.updateTitle(task.title, subtitle: "Photo \(done) of \(items.count)")
    }
    task.setTaskCompleted(success: !Task.isCancelled)
}
```

When the task expires, the cancelled `Task` leaves the loop and
`runExport` reports `success: false`, so completion still happens exactly once. The
`nonisolated(unsafe)` copy in `handleExport` is there for the same reason as in
`perform`.

- Each job gets its own identifier under a permitted wildcard base, which
  also keeps every registration unique.
- `earliestBeginDate` is ignored for this request type.
- `strategy` is `.queue` (start as soon as allowed) or `.fail` (reject the
  submission if it cannot start now).
- GPU work needs both the `supportedResources` check and the entitlement for
  background GPU use (key ending in `continued-processing.gpu`).

Strategy choice, cancellation and progress ranking are expanded in the
[patterns reference](references/background-task-patterns.md).

## Background URLSession

A session built with `URLSessionConfiguration.background(withIdentifier:)`
hands transfers to a system daemon, so they keep running while the app is
suspended or even terminated.

```swift
@MainActor
final class EpisodeDownloader: NSObject, URLSessionDownloadDelegate {
    static let shared = EpisodeDownloader()

    var finishEvents: (() -> Void)?

    lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(
            withIdentifier: "org.sample.podcasts.downloads"
        )
        config.sessionSendsLaunchEvents = true
        config.isDiscretionary = true
        return URLSession(
            configuration: config,
            delegate: self,
            delegateQueue: nil
        )
    }()

    func fetch(_ remote: URL, notBefore date: Date? = nil) {
        let job = session.downloadTask(with: remote)
        job.earliestBeginDate = date
        job.resume()
    }

    nonisolated func urlSession(_ s: URLSession, downloadTask job: URLSessionDownloadTask, didFinishDownloadingTo tmp: URL) {
        let name = job.response?.suggestedFilename ?? "episode-\(job.taskIdentifier)"
        let target = URL.documentsDirectory.appending(path: name)
        try? FileManager.default.moveItem(at: tmp, to: target)
    }

    nonisolated func urlSession(_ s: URLSession, task job: URLSessionTask, didCompleteWithError error: (any Error)?) {
        guard let error else { return }
        Log.background.error("Transfer failed: \(error)")
    }

    nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession s: URLSession) {
        Task { @MainActor in
            self.finishEvents?()
            self.finishEvents = nil
        }
    }
}
```

- The class is `@MainActor`, so the lazy session and the stored handler are
  safe to touch from UIKit callbacks. The session calls its delegate on its
  own queue, hence the `nonisolated` delegate methods.
- `isDiscretionary` lets the system wait for good conditions. With
  `sessionSendsLaunchEvents` on, finished transfers launch the app again.
- A delegate is required (`delegateQueue: nil` is fine). The async and
  completion-handler task APIs do not work with background sessions.
- The temporary file disappears when `didFinishDownloadingTo` returns, so move
  it inside that method.
- Failures arrive in `urlSession(_:task:didCompleteWithError:)`.

When the system relaunches the app for session events, keep the handler it
gives you and call it once the delegate reports the events are done:

```swift
func application(_ app: UIApplication,
                 handleEventsForBackgroundURLSession id: String,
                 completionHandler done: @escaping () -> Void) {
    EpisodeDownloader.shared.finishEvents = done
    _ = EpisodeDownloader.shared.session
}
```

## Background pushes

A silent push wakes the app for a short time. The payload carries
`content-available: 1` plus your own keys. On the APNs request, set the
push type header to `background` and the priority header to `5`.

```json
{
  "aps": { "content-available": 1 },
  "album-id": "A41"
}
```

Delivery is low priority and never guaranteed. Keep it to two or three per hour
at most. Enable Remote notifications under Background Modes and call
`UIApplication.shared.registerForRemoteNotifications()`.

```swift
func application(_ app: UIApplication,
                 didReceiveRemoteNotification payload: [AnyHashable: Any],
                 fetchCompletionHandler reply: @escaping (UIBackgroundFetchResult) -> Void) {
    guard let albumID = payload["album-id"] as? String else {
        return reply(.noData)
    }
    Task {
        let outcome: Result<Bool, any Error> = await LibrarySync.shared.refreshAlbum(albumID)
        switch outcome {
        case .success(true): reply(.newData)
        case .success(false): reply(.noData)
        case .failure: reply(.failed)
        }
    }
}
```

## Common mistakes

- **Identifier missing from Info.plist.** `submit` throws `.notPermitted`.
- **Skipping `setTaskCompleted(success:)`.** The system schedules the app less
  often afterwards. Complete on every path, errors included.
- **No expiration handler.** The task is killed abruptly instead of stopping
  cleanly. Cancel the work from the handler and let the cancelled work report
  `success: false`, so completion happens once.
- **Asking too often.** A refresh every minute gets throttled hard. Use 15
  minutes or more, and remember `earliestBeginDate` is a hint.
- **Too much work per run.** A ten-minute job inside a refresh task will not
  finish. Split work into small cancellable batches, call
  `try Task.checkCancellation()` between them and save progress.

## Review checklist

- [ ] Every identifier is in `BGTaskSchedulerPermittedIdentifiers`
- [ ] `UIBackgroundModes` has `fetch` and/or `processing` as needed
- [ ] Refresh and processing handlers are registered before launch completes;
      continued processing handlers are registered before their `submit`
- [ ] `setTaskCompleted(success:)` runs exactly once on every path
- [ ] `expirationHandler` cancels in-flight work
- [ ] The handler schedules the next task
- [ ] `earliestBeginDate` is sensible and read as a lower bound only
- [ ] Background `URLSession` uses a delegate, not async or closure APIs
- [ ] Downloaded files are moved inside `didFinishDownloadingTo`
- [ ] `handleEventsForBackgroundURLSession` stores its handler and it is called later
- [ ] Silent push payload has `content-available: 1`
- [ ] APNs headers: push type `background`, priority `5`
- [ ] `fetchCompletionHandler` is called promptly with the right result
- [ ] Continued processing tasks report progress through `ProgressReporting`
- [ ] Work is incremental and checks cancellation (`Task.checkCancellation()`)
- [ ] Handlers never block on synchronous work

## References

- [Background task patterns](references/background-task-patterns.md):
  debugging, error codes, checkpointing, URLSession extras, push limits,
  SwiftUI `.backgroundTask`, continued-processing details
- Apple documentation:
  [scheduler](https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler),
  [refresh task](https://developer.apple.com/documentation/backgroundtasks/bgapprefreshtask),
  [processing task](https://developer.apple.com/documentation/backgroundtasks/bgprocessingtask),
  [continued processing task](https://developer.apple.com/documentation/backgroundtasks/bgcontinuedprocessingtask) (iOS 26+),
  [continued processing request](https://developer.apple.com/documentation/backgroundtasks/bgcontinuedprocessingtaskrequest) (iOS 26+),
  [keeping content fresh with background tasks](https://developer.apple.com/documentation/uikit/using-background-tasks-to-update-your-app),
  [long-running work](https://developer.apple.com/documentation/backgroundtasks/performing-long-running-tasks-on-ios-and-ipados)
