Background Execution
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A phone app spends most of its life not running. When a person switches away from FieldNotes, the system gives it a moment to tidy up and then suspends it: the process stays in memory, but none of its code runs. This keeps the battery healthy and the foreground app fast, and it means that "sync every hour" or "keep exporting after I lock the phone" is never automatic. The app has to ask for background time, and the system decides whether and when to grant it. This page covers the ways to ask, using the 2026 baseline of this section: iOS, iPadOS, macOS, watchOS and visionOS 27, Xcode 27 and Swift 6.4, with a deployment minimum of 26. It adds FieldNotes' three background tasks, whose identifiers are fixed for the whole section:
-
com.example.fieldnotes.refresh: an app refresh task that syncs notes a few times a day; -
com.example.fieldnotes.reindex: a processing task that rebuilds the on-device search index while the device charges; -
com.example.fieldnotes.export: a continued processing task that finishes a notebook export the person started, even after they leave the app.
Structured concurrency, cancellation and actors are not re-explained here. They are covered on Async/Await and Tasks and Actors, Isolation and Sendable, and their use inside the app on Swift Concurrency in Apps.
The Suspension Model
On iOS, iPadOS and visionOS an app process moves through a small set of states. The scene phases that SwiftUI reports
(active, inactive, background) are described on App Structure
and Life Cycle; underneath them are two process states the app never sees directly, suspended and terminated:
| State | What happens |
|---|---|
Foreground (active or inactive) |
The app is on screen and runs freely. |
Background |
The last scene has left the screen. Code still runs for a short grace period (a few seconds), or for as long as a granted background task, background mode or session lasts. |
Suspended |
The process is frozen in memory. No timers fire, no network callbacks arrive, and |
Terminated |
The system reclaimed the memory. The next launch starts from scratch, possibly in the background because a task the app scheduled is now due. |
Two things follow from this. First, work that is running when the app goes to the background simply stops at the next
suspension point unless the app has asked for time. Second, a background task often starts with a cold launch:
FieldNotesApp is created, its scenes are not shown, and only the task handler runs. Anything the handler needs,
such as the NoteStore, must be reachable without a window.
macOS is different. Mac apps are not suspended when they lose focus; they keep running until they quit, and the system only throttles them through App Nap. That is why the BackgroundTasks framework is not on macOS at all. watchOS suspends even more aggressively than iOS and has its own session types, covered at the end of this page.
Figure: A background task’s life, from foreground to relaunch
The same refresh task can therefore start in two ways: by resuming a suspended FieldNotes process, or by
launching a terminated one straight into the background. Handlers must work in both cases.
Choosing a Strategy
The system offers several kinds of background time, each shaped for one job. Picking the wrong one is the most common mistake: a processing task is not a timer, and an app refresh task is not a way to upload a video. Apple’s guide Choosing Background Strategies for Your App sorts them by the question "when does the work need to happen?":
| Need | API | FieldNotes use |
|---|---|---|
Finish something already in progress when the person leaves |
|
Let an in-flight sync finish instead of being cut off. |
Transfer files, possibly for hours |
Background |
Download photo attachments (session |
Fetch fresh content a few times a day |
|
|
Heavy maintenance that can wait for power and idle time |
|
|
Finish a job the person explicitly started, with visible progress |
|
|
React when the server says something changed |
Background push ( |
Pull a note a collaborator just shared (see Notifications, APNs and Live Activities). |
Run an App Intent for longer than 30 seconds |
|
Export Notebook from Shortcuts. |
Keep running for a continuous activity |
Background modes (audio, location, VoIP), |
Record a field trip’s route (location). |
Figure: Choosing a background strategy
and expect it to finish?} Q1 -- "Yes, seconds" --> BBT["beginBackgroundTask
(finish a save or sync)"] Q1 -- "Yes, minutes, with progress" --> CPT["BGContinuedProcessingTask (26)
com.example.fieldnotes.export"] Q1 -- "Yes, from Shortcuts or Siri" --> LRI["LongRunningIntent (27)"] Q1 -- No --> Q2{Is it a file transfer?} Q2 -- Yes --> URLS["Background URLSession
.backgroundTask(.urlSession)"] Q2 -- No --> Q3{Does the server know
when data changed?} Q3 -- Yes --> PUSH["Background push
remote-notification mode"] Q3 -- No --> Q4{Is it heavy and deferrable
until charging?} Q4 -- Yes --> PROC["BGProcessingTask
com.example.fieldnotes.reindex"] Q4 -- No --> Q5{Is it a continuous activity
such as audio or a route?} Q5 -- Yes --> MODE["Background mode
(audio, location, voip)
or WKExtendedRuntimeSession"] Q5 -- No --> REFRESH["BGAppRefreshTask
com.example.fieldnotes.refresh"]
Everything in the scheduled branches is discretionary. The system weighs battery level, Low Power Mode, how often the person opens FieldNotes, and whether they turned off Background App Refresh in Settings. An app that is rarely used gets fewer refreshes; an app that is force-quit from the app switcher gets none until it is opened again. Design every task so that not running is acceptable: the foreground path must still sync on launch.
Finishing Work After Leaving the Foreground
The simplest request is "let me finish what I’m doing". beginBackgroundTask(withName:expirationHandler:) returns an
identifier and gives the app extra time, usually well under a minute. The app must call endBackgroundTask(_:) when
the work is done and from the expiration handler; an app that holds a background task past its time is terminated.
FieldNotes uses it when a scene moves to the background during a sync. The identifier is stored in a small main-actor object, so the expiration handler and the work can both end it without sharing a mutable local variable:
// FieldNotes/Background/SyncFinisher.swift
#if os(iOS) || os(visionOS)
import UIKit
import FieldNotesServices
@MainActor
final class SyncFinisher {
private var taskID: UIBackgroundTaskIdentifier = .invalid
/// Keeps the process alive until the current sync finishes or the system runs out of patience.
func finishSync(of store: NoteStore) {
guard taskID == .invalid else { return }
taskID = UIApplication.shared.beginBackgroundTask(withName: "Finish FieldNotes sync") {
self.end() // time is up: stop cleanly rather than be terminated
}
Task {
try? await store.refresh()
end()
}
}
private func end() {
guard taskID != .invalid else { return }
UIApplication.shared.endBackgroundTask(taskID)
taskID = .invalid
}
}
#endif
The ContentView calls syncFinisher.finishSync(of: store) from its scenePhase handler when the phase becomes
.background, next to the saveIfNeeded() call shown on App
Structure and Life Cycle. The expiration handler is @MainActor, so it can touch self directly.
Background Modes and Permitted Identifiers
Two Info.plist keys declare what an app may do in the background. Xcode writes both when you add the Background
Modes capability under Signing & Capabilities and tick the boxes:
-
UIBackgroundModeslists the modes: long-lived reasons to run (audio, location, VoIP), plus the modes that let the scheduler and push wake the app (fetch,processing,remote-notification). -
BGTaskSchedulerPermittedIdentifierslists every task identifier the app will register. Registering or submitting an identifier that is not in this list fails. Prefix each identifier with the bundle identifier.
FieldNotes' iOS target declares:
<!-- FieldNotes/Info.plist (iOS, iPadOS and visionOS app target) -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string> <!-- BGAppRefreshTask: com.example.fieldnotes.refresh -->
<string>processing</string> <!-- BGProcessingTask: com.example.fieldnotes.reindex -->
<string>remote-notification</string> <!-- background pushes that announce a shared note -->
<string>location</string> <!-- recording a field trip's route with the screen off -->
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.fieldnotes.refresh</string>
<string>com.example.fieldnotes.reindex</string>
<string>com.example.fieldnotes.export</string>
</array>
Continued processing identifiers (26) may also use a wildcard. An entry such as
com.example.fieldnotes.export.* permits any identifier with that prefix, so an app could register one identifier
per job, for example with a notebook ID appended. FieldNotes runs one export at a time and keeps the plain
com.example.fieldnotes.export identifier.
The modes that keep an app running are limited to specific kinds of work, and App Review checks that the app really does that work:
| Mode | Value | What it allows |
|---|---|---|
Audio, AirPlay and Picture in Picture |
|
Keep playing audible content after leaving the foreground. It needs an active |
Location updates |
|
Keep receiving location updates. With the live-updates API, the app holds a |
Voice over IP |
|
Be woken by PushKit VoIP pushes. Every VoIP push must be reported to CallKit as an incoming call. |
Background fetch |
|
Be launched for |
Background processing |
|
Be launched for |
Remote notifications |
|
Be woken by background pushes ( |
FieldNotes has no audio or calls, so only the location mode applies. A field trip keeps its route recording alive with a background activity session, which is all the location mode needs from the app side (the tracking itself is on Location and Maps):
// Inside LocationService (Location and Maps), while a field trip is recording.
#if os(iOS) || os(watchOS)
import CoreLocation
// Holding the session keeps location updates flowing while FieldNotes is in the background,
// and the system shows the location indicator so the person knows why.
let backgroundSession = CLBackgroundActivitySession()
// When the trip ends:
backgroundSession.invalidate()
#endif
For comparison, an audio app would configure its session before starting playback, and a calling app would register
a PKPushRegistry for VoIP pushes. FieldNotes does neither; the shapes are shown only so the modes above have a
concrete example:
#if os(iOS)
import AVFAudio
import PushKit
// Audio mode: playback continues in the background only with a playback category.
func configureVoiceMemoPlayback() throws {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
try AVAudioSession.sharedInstance().setActive(true)
}
// VoIP mode: the registry delivers VoIP pushes; each must be reported to CallKit.
func makeVoIPRegistry(delegate: any PKPushRegistryDelegate) -> PKPushRegistry {
let registry = PKPushRegistry(queue: .main)
registry.delegate = delegate
registry.desiredPushTypes = [.voIP]
return registry
}
#endif
Background pushes
With the remote-notification mode, a push that carries "content-available": 1 and no alert wakes the app for up
to about 30 seconds. The provider sends it with apns-push-type: background and apns-priority: 5, and the system
throttles apps that send more than a few per hour. FieldNotes handles it in the app delegate from
App Structure and Life Cycle, using the async form of the delegate
method:
// FieldNotes/iOS/FieldNotesAppDelegate+BackgroundPush.swift
#if os(iOS) || os(visionOS)
import UIKit
extension FieldNotesAppDelegate {
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
guard userInfo["sharedNoteID"] != nil else { return .noData }
do {
try await AppServices.shared.store.refresh()
return .newData
} catch {
return .failed
}
}
}
#endif
See Apple Developer Documentation -- application(_:didReceiveRemoteNotification:fetchCompletionHandler:).
AppServices.shared is a small main-actor holder. A background launch creates no window, so code that the system
calls, such as delegates and task handlers, cannot get the store from the SwiftUI environment. AppServices owns the
one NoteStore, and FieldNotesApp takes its @State from it, so the views and the delegates share one instance:
// FieldNotes/AppServices.swift
import Observation
import FieldNotesModel
import FieldNotesServices
@Observable
@MainActor
final class AppServices {
static let shared = AppServices()
/// The App Group SwiftData store opened by `SharedNoteAccess` (see the SwiftData and Widgets pages).
let persistence: SwiftDataNotePersistence
/// The one store for the whole app process: every scene injects it with `.environment(store)`, and
/// delegates, task handlers and App Intents reach it through `AppServices.shared`.
let store: NoteStore
/// Set by the notification delegate; ContentView navigates to it like a deep link.
var pendingNoteID: Note.ID?
private init() {
let persistence: SwiftDataNotePersistence
do {
persistence = try SharedNoteAccess.persistence()
} catch {
// As on the SwiftData page: a store that can't be opened is a deployment error, not a runtime state.
fatalError("FieldNotes could not open its store: \(error)")
}
self.persistence = persistence
self.store = NoteStore(syncer: CloudNoteSyncer(), persistence: persistence)
}
}
The payload and the provider side are on Notifications, APNs and Live Activities.
This example needs the Push Notifications capability, a paid developer account and a physical device; the Simulator can receive simulated pushes, but not real ones from APNs.
Scheduled Tasks with BGTaskScheduler
BGTaskScheduler is the scheduler behind both scheduled task kinds, and it superseded the older
setMinimumBackgroundFetchInterval(:)/application(:performFetchWithCompletionHandler:) background fetch API,
which Apple deprecated in iOS 13 and which new code should not use. The pattern is always the same:
-
Register a launch handler for the identifier. For refresh and processing tasks this must happen before the app finishes launching, because a background launch calls the handler straight away.
-
Submit a request (
BGAppRefreshTaskRequestorBGProcessingTaskRequest) with the same identifier. A request is a hint, not an appointment:earliestBeginDateis the earliest time, never a guaranteed one. -
When the system runs the task, do the work, then call
setTaskCompleted(success:). Set anexpirationHandlerthat cancels the work if time runs out. -
Submit the next request if the task should repeat. Tasks are one-shot.
An app refresh task gets about 30 seconds. A processing task can run for minutes, but only when the system decides
the device is idle; setting requiresExternalPower makes it wait for a charger.
App refresh from SwiftUI
SwiftUI wraps registration in the backgroundTask(_:action:) scene modifier. The action is an async closure: the
task counts as complete when the closure returns, and it is cancelled (in the Swift concurrency sense) when time
runs out, so there is no expiration handler to write. FieldNotes schedules the next refresh at launch and at the start
of every refresh:
// FieldNotes/Background/RefreshScheduler.swift (shared by the iOS, visionOS and watchOS app targets)
import Foundation
#if os(iOS) || os(visionOS)
import BackgroundTasks
#elseif os(watchOS)
import WatchKit
#endif
enum RefreshScheduler {
static let identifier = "com.example.fieldnotes.refresh"
/// Asks for the next refresh no earlier than `interval` from now. Submitting again replaces the pending request.
@MainActor
static func scheduleNext(after interval: TimeInterval = 2 * 60 * 60) {
let earliest = Date.now.addingTimeInterval(interval)
#if os(iOS) || os(visionOS)
let request = BGAppRefreshTaskRequest(identifier: identifier)
request.earliestBeginDate = earliest
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule refresh: \(error)") // for example, Background App Refresh is off
}
#elseif os(watchOS)
WKApplication.shared().scheduleBackgroundRefresh(withPreferredDate: earliest, userInfo: nil) { error in
if let error { print("Could not schedule refresh: \(error)") }
}
#endif
}
}
The scene hands the work to the store. The closure is @Sendable and runs off the main actor, so it captures the
store explicitly and awaits its main-actor methods:
// FieldNotes/FieldNotesApp.swift (excerpt: background tasks; the other scenes are on App Structure and Life Cycle)
import SwiftUI
import FieldNotesServices
@main
struct FieldNotesApp: App {
// The same instance that delegates and task handlers reach through AppServices.shared.
@State private var store = AppServices.shared.store
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
.task { RefreshScheduler.scheduleNext() }
}
#if os(iOS) || os(visionOS)
.backgroundTask(.appRefresh(RefreshScheduler.identifier)) { [store] in
await RefreshScheduler.scheduleNext() // keep the chain going even if this run fails
try? await store.refresh()
}
#endif
.backgroundTask(.urlSession(AttachmentDownloader.sessionIdentifier)) {
// The system relaunched FieldNotes because com.example.fieldnotes.attachments finished transfers.
// Touching AttachmentDownloader.shared recreates the session (see Networking); returning after
// its events are delivered lets the system suspend the app again.
await AttachmentDownloader.shared.waitForBackgroundEvents()
}
}
}
BackgroundTask.appRefresh(_:) exists on iOS, iPadOS, visionOS and watchOS, and urlSession(_:) on every platform
including macOS. Use either the SwiftUI modifier or BGTaskScheduler.register for a given identifier, never both.
The background URL session itself is created on Networking: URLSession, Network Framework and ATS.
Processing tasks with BGTaskScheduler
The reindex task rebuilds FieldNotes' Core Spotlight entries. It is heavy, and nothing breaks if it waits until
tonight, which makes it a textbook processing task. With a deployment minimum of 26, FieldNotes registers it the
classic way, from the app delegate, before application(_:didFinishLaunchingWithOptions:) returns:
// FieldNotes/Background/ReindexTask.swift
#if os(iOS) || os(visionOS)
import BackgroundTasks
import CoreSpotlight
import FieldNotesModel
enum ReindexTask {
static let identifier = "com.example.fieldnotes.reindex"
/// Call from application(_:didFinishLaunchingWithOptions:).
@MainActor
static func register() {
// `.main` runs the launch handler on the main queue, matching the main-actor isolation of this code.
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: .main) { task in
guard let task = task as? BGProcessingTask else { return }
let notes = AppServices.shared.store.notes
let work = Task {
do {
try await rebuildSearchIndex(for: notes)
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = { @Sendable in work.cancel() }
schedule()
}
}
static func schedule() {
let request = BGProcessingTaskRequest(identifier: identifier)
request.requiresExternalPower = true
request.requiresNetworkConnectivity = false
try? BGTaskScheduler.shared.submit(request)
}
static func rebuildSearchIndex(for notes: [Note]) async throws {
let items = notes.map { note in
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.body
return CSSearchableItem(uniqueIdentifier: note.id.uuidString,
domainIdentifier: "notes", attributeSet: attributes)
}
try Task.checkCancellation()
let index = CSSearchableIndex.default()
try await index.deleteAllSearchableItems()
try await index.indexSearchableItems(items)
}
}
#endif
The part 1 delegate gains one line in application(_:didFinishLaunchingWithOptions:):
ReindexTask.register(), followed by ReindexTask.schedule(). The richer, App Intents-based index, where each note is
an IndexedEntity, is on App Intents, Siri, Shortcuts, Spotlight, Deep Links and Extensions; the scheduling stays the same.
New in iOS 27: processingTask(_:). SwiftUI now has a processing counterpart to appRefresh(_:), available on
iOS, iPadOS, Mac Catalyst, tvOS and watchOS 27 (not visionOS). A scene modifier cannot be applied behind an
#available check, so FieldNotes keeps the delegate registration while its minimum is 26. Once the minimum becomes 27,
the delegate code above reduces to one modifier on the WindowGroup:
// FieldNotesApp body, once the deployment minimum is 27.
.backgroundTask(.processingTask(ReindexTask.identifier)) { [store] in
let notes = await store.notes
try? await ReindexTask.rebuildSearchIndex(for: notes)
await ReindexTask.schedule() // SwiftUI registers the handler; the request is still submitted by the app
}
Continued Processing for Work the Person Starts (26)
Refresh and processing tasks run when the system chooses. BGContinuedProcessingTask, new in iOS and iPadOS 26,
covers the opposite case: the person taps Export Notebook, then switches to Mail while the export runs. The task
starts in the foreground, keeps running in the background, and the system shows its progress in a system Live
Activity where the person can watch or cancel it. Requirements differ from the scheduled tasks:
-
It must start from an explicit user action, and its title and subtitle must describe that job.
-
The launch handler may be registered at any time before submitting, not only during launch.
-
It must report progress through its
progressobject. The system may end tasks that appear stuck. -
strategy = .failmakes submission fail if the system cannot start it right away; the default,.queue, waits. -
requiredResources = .gpuasks for background GPU access, whenBGTaskScheduler.supportedResourcescontains.gpu. It needs the Background GPU Access capability.
FieldNotes' export controller registers on first use, submits the request, and writes each note as it goes:
// FieldNotes/Export/NotebookExportController.swift
#if os(iOS)
import BackgroundTasks
import FieldNotesModel
import FieldNotesServices
@MainActor
final class NotebookExportController {
static let identifier = "com.example.fieldnotes.export"
private var isRegistered = false
private var pending: (notebook: Notebook, notes: [Note], destination: URL)?
/// Called from the Export button: the person started this job, so continued processing fits.
func export(_ notebook: Notebook, notes: [Note], to destination: URL) throws {
if !isRegistered {
isRegistered = BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.identifier, using: .main
) { task in
guard let task = task as? BGContinuedProcessingTask else { return }
self.run(task)
}
}
pending = (notebook, notes, destination)
let request = BGContinuedProcessingTaskRequest(
identifier: Self.identifier,
title: "Exporting \(notebook.name)",
subtitle: "Preparing \(notes.count) notes"
)
request.strategy = .queue
try BGTaskScheduler.shared.submit(request)
}
private func run(_ task: BGContinuedProcessingTask) {
guard let job = pending else {
task.setTaskCompleted(success: false)
return
}
pending = nil
task.progress.totalUnitCount = Int64(job.notes.count)
let work = Task {
// Measured as a MetricKit interval; MetricsReporter is on Logging, MetricKit, Crash Reports and Organizer.
MetricsReporter.shared.exportIntervalDidChange(started: true)
defer { MetricsReporter.shared.exportIntervalDidChange(started: false) }
do {
let writer = try NotebookArchiveWriter(destination: job.destination)
for (index, note) in job.notes.enumerated() {
try Task.checkCancellation() // the person tapped Stop in the system UI
try await writer.append(note)
task.progress.completedUnitCount = Int64(index + 1)
task.updateTitle(task.title, subtitle: "\(index + 1) of \(job.notes.count) notes")
}
try await writer.close()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = { @Sendable in work.cancel() }
}
}
#endif
NotebookArchiveWriter is a small actor in FieldNotesServices that appends each note to a Markdown file. It is
deliberately simple, so the example stays about the task; the document-based export (NotebookExportDocument) is on
Preferences, Files and Document-Based Apps:
// Packages/FieldNotesKit/Sources/FieldNotesServices/Export/NotebookArchiveWriter.swift
import Foundation
import FieldNotesModel
public actor NotebookArchiveWriter {
private let handle: FileHandle
public init(destination: URL) throws {
FileManager.default.createFile(atPath: destination.path(), contents: nil)
handle = try FileHandle(forWritingTo: destination)
}
public func append(_ note: Note) throws {
let markdown = "# \(note.title)\n\n\(note.body)\n\n"
try handle.write(contentsOf: Data(markdown.utf8))
}
public func close() throws {
try handle.close()
}
}
If the person closes FieldNotes from the app switcher, the system cancels the task without calling the expiration handler, so a half-written file can remain. The next export overwrites it.
Long-Running App Intents (27)
An App Intent that runs in the background from Shortcuts, Siri or a widget normally has about 30 seconds. New in the
27 releases, an intent that adopts LongRunningIntent can ask for more time by wrapping its work in
performBackgroundTask(options:operation:). The protocol refines ProgressReportingIntent, and the system expects
regular updates to progress; an intent that stops reporting can lose its extension. On macOS there is no 30-second
limit in the first place.
FieldNotes offers an Export Notebook shortcut that reuses the same writer. The intent types and the dependency
registration for NoteStore are covered on App Intents, Siri, Shortcuts, Spotlight, Deep Links and Extensions:
// FieldNotes/Intents/ExportNotebookIntent.swift
import AppIntents
import Foundation
import FieldNotesModel
import FieldNotesServices
@available(iOS 27, macOS 27, visionOS 27, watchOS 27, *)
struct ExportNotebookIntent: LongRunningIntent {
static let title: LocalizedStringResource = "Export Notebook"
@Parameter(title: "Notebook Name")
var notebookName: String
@Dependency private var store: NoteStore
func perform() async throws -> some IntentResult & ProvidesDialog {
let store = store
let name = notebookName
let notes = await MainActor.run { () -> [Note]? in
guard let notebook = store.notebooks.first(where: { $0.name == name }) else { return nil }
return store.notes.filter { $0.notebookID == notebook.id }
}
guard let notes else {
// Never fall back to "notes without a notebook": an unknown name is an error the person can fix.
throw $notebookName.needsValueError("There's no notebook named \(name). Which notebook?")
}
let destination = URL.documentsDirectory.appending(path: "\(notebookName).md")
let written = try await performBackgroundTask {
progress.totalUnitCount = Int64(notes.count)
progress.localizedDescription = "Exporting \(notebookName)"
let writer = try NotebookArchiveWriter(destination: destination)
for (index, note) in notes.enumerated() {
try Task.checkCancellation()
try await writer.append(note)
progress.completedUnitCount = Int64(index + 1)
}
try await writer.close()
return notes.count
}
return .result(dialog: "Exported \(written) notes from \(notebookName).")
}
}
See Apple Developer Documentation — LongRunningIntent and Apple Developer Documentation -- needsValueError(_:).
LongRunningTaskOptions currently has one option, .requiresGPU, for intents that run Core ML or Metal work in the
background. An intent that also adopts CancellableIntent can use the onCancel: overload to learn why it was
cancelled.
watchOS: Refresh Tasks and Extended Runtime Sessions
watchOS has no BackgroundTasks framework. Its scheduled work arrives through WKApplicationDelegate.handle(_:), as the
WatchAppDelegate on App Structure and Life Cycle shows, or through
the same SwiftUI .backgroundTask(.appRefresh(_:)) modifier. RefreshScheduler.scheduleNext() above already contains
the watchOS branch. The watch gets a small refresh budget, which is best spent keeping a complication current.
For work that must continue while the wrist is down, watchOS offers WKExtendedRuntimeSession. A session has a
type, chosen in the Background Modes capability of the watch app: self care, mindfulness, physical therapy or smart
alarm. App Review expects the app’s feature to match the type. FieldNotes' watch app offers a ten-minute Quiet
Observation timer, declared as a mindfulness session:
// FieldNotesWatch/ObservationTimer.swift
#if os(watchOS)
import WatchKit
@MainActor
final class ObservationTimer: NSObject, WKExtendedRuntimeSessionDelegate {
private var session: WKExtendedRuntimeSession?
func start() {
let session = WKExtendedRuntimeSession()
session.delegate = self
session.start()
self.session = session
}
func stop() {
session?.invalidate()
session = nil
}
nonisolated func extendedRuntimeSessionDidStart(_ extendedRuntimeSession: WKExtendedRuntimeSession) {
// Start the countdown UI; the app keeps running with the screen down.
}
nonisolated func extendedRuntimeSessionWillExpire(_ extendedRuntimeSession: WKExtendedRuntimeSession) {
// Save the observation now: the session is about to end.
}
nonisolated func extendedRuntimeSession(
_ extendedRuntimeSession: WKExtendedRuntimeSession,
didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
error: (any Error)?
) {
Task { @MainActor in self.session = nil }
}
}
#endif
Workout apps use an HKWorkoutSession instead, which is how FieldNotes tracks a hike
(Health, Motion and Accessories). Complications, refresh budgets
and the rest of the watch-specific picture are on watchOS: App Structure, Connectivity, Workouts and the Smart Stack.
macOS: Scheduling Low-Priority Work
A Mac app keeps running in the background, so it needs no permission to run. What it should do is let the system
pick a good moment for maintenance. NSBackgroundActivityScheduler does that: give it an interval and a tolerance,
and it calls the block when the Mac is idle and on power. FieldNotes' Mac build uses it for the same reindex work:
// FieldNotes/macOS/MacReindexScheduler.swift
#if os(macOS)
import Foundation
@MainActor
final class MacReindexScheduler {
private let scheduler = NSBackgroundActivityScheduler(identifier: "com.example.fieldnotes.reindex")
func start() {
scheduler.repeats = true
scheduler.interval = 24 * 60 * 60
scheduler.tolerance = 60 * 60
scheduler.qualityOfService = .background
scheduler.schedule { completion in
// Rebuild the Spotlight entries here, then report back.
completion(.finished)
}
}
}
#endif
Debugging Background Tasks
The gap between submitting a task and the system running it can be hours, which makes waiting for it useless during development. BackgroundTasks has two private debugger entry points that start a task, or force it to expire, right away. They work only on a device, and only from the debugger; referencing them in shipping code is grounds for App Store rejection.
-
Run FieldNotes from Xcode on a device.
-
Pause after
submit(_:)has run, for example with a breakpoint inRefreshScheduler.scheduleNext(). -
In the LLDB console, launch the task, then continue:
(lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.fieldnotes.refresh"]
(lldb) continue
To exercise the expiration path, pause inside the running task and simulate its expiration:
(lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.example.fieldnotes.reindex"]
(lldb) continue
A few more habits help:
-
Check that the identifier is in
BGTaskSchedulerPermittedIdentifierswheneversubmit(_:)throws;BGTaskScheduler.Error.Code.notPermittedis the usual reason, and.unavailablemeans Background App Refresh is off or the app runs in the Simulator. -
Filter the device log in Console.app by the
dasdprocess to see the scheduler’s decisions about your tasks. -
Test continued processing by starting an export and then pressing the Home button; the system progress UI should appear, and its Stop button should cancel the Swift
Task. -
Test with Low Power Mode on and with Background App Refresh off. The app must still sync when it is opened.
General LLDB and Xcode debugging techniques are on Debugging with Xcode and LLDB.
Availability and Requirements
Platform availability
| API or feature | iOS | iPadOS | macOS | watchOS | visionOS |
|---|---|---|---|---|---|
|
Yes |
Yes |
Not available (Mac Catalyst only) |
Not available |
Yes |
|
Yes |
Yes |
Not available (Mac Catalyst only) |
Not available |
Yes |
|
New in 26 |
New in 26 |
Not available (Mac Catalyst 26) |
Not available |
Not available |
SwiftUI |
Yes |
Yes |
Yes |
Yes |
Yes |
SwiftUI |
Yes |
Yes |
Not available |
Yes |
Yes |
SwiftUI |
New in 27 |
New in 27 |
Not available (Mac Catalyst 27) |
New in 27 |
Not available |
|
Yes |
Yes |
Not available |
Yes |
Yes |
|
New in 27 |
New in 27 |
New in 27 |
New in 27 |
New in 27 |
|
Not available |
Not available |
Not available |
Yes |
Not available |
|
Not available |
Not available |
Yes |
Not available |
Not available |
Requirements
-
Background modes are declared with the Background Modes capability; the Background GPU Access capability is needed for
requiredResources = .gpu. -
Background pushes need the Push Notifications capability and a paid developer account.
-
The
_simulateLaunchForTaskWithIdentifierand_simulateExpirationForTaskWithIdentifiercommands need a physical device. -
Extended runtime sessions need the watch app’s Background Modes capability with a session type selected.
Learning More
The WWDC25 session Finish tasks in the background explains how the system schedules background runtime and walks through continued processing tasks from registration to progress reporting. It is the best single overview of the topic, and it pairs well with Notifications, APNs and Live Activities for the push-driven side and Networking: URLSession, Network Framework and ATS for background transfers.
References
-
Apple Developer Documentation — Choosing Background Strategies for Your App
-
Apple Developer Documentation — Performing long-running tasks on iOS and iPadOS
-
Apple Developer Documentation — Starting and Terminating Tasks During Development
-
Apple Developer Documentation — Configuring background execution modes
-
Apple Developer Documentation — beginBackgroundTask(withName:expirationHandler:)
-
Apple Developer Documentation -- application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
-
Apple Developer Documentation — NSBackgroundActivityScheduler