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 Tasks make no progress. Resuming is instant.

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

stateDiagram-v2 [*] --> Foreground: person opens FieldNotes Foreground --> Background: last scene leaves the screen Background --> Suspended: grace period ends, no task running Background --> Background: beginBackgroundTask / continued processing keeps running Suspended --> Terminated: system reclaims memory Suspended --> Background: scheduled task is due (process still in memory) Terminated --> Background: system relaunches the app for a due task Background --> Suspended: handler returns or setTaskCompleted is called Background --> Foreground: person returns to the app note right of Background Only here does task code run. The expiration handler fires if time runs out. end note

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

UIApplication.beginBackgroundTask(withName:expirationHandler:)

Let an in-flight sync finish instead of being cut off.

Transfer files, possibly for hours

Background URLSession, handled with .backgroundTask(.urlSession(_:))

Download photo attachments (session com.example.fieldnotes.attachments, see Networking: URLSession, Network Framework and ATS).

Fetch fresh content a few times a day

BGAppRefreshTask, or .backgroundTask(.appRefresh(_:))

com.example.fieldnotes.refresh.

Heavy maintenance that can wait for power and idle time

BGProcessingTask, or .backgroundTask(.processingTask(_:)) (new in 27)

com.example.fieldnotes.reindex.

Finish a job the person explicitly started, with visible progress

BGContinuedProcessingTask (new in 26)

com.example.fieldnotes.export.

React when the server says something changed

Background push (content-available), remote-notification mode

Pull a note a collaborator just shared (see Notifications, APNs and Live Activities).

Run an App Intent for longer than 30 seconds

LongRunningIntent (new in 27)

Export Notebook from Shortcuts.

Keep running for a continuous activity

Background modes (audio, location, VoIP), WKExtendedRuntimeSession on watchOS

Record a field trip’s route (location).

Figure: Choosing a background strategy

flowchart TD Start([Work must happen while FieldNotes is not in the foreground]) --> Q1{Did the person just start it
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:

  • UIBackgroundModes lists 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).

  • BGTaskSchedulerPermittedIdentifiers lists 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

audio

Keep playing audible content after leaving the foreground. It needs an active AVAudioSession with a playback category.

Location updates

location

Keep receiving location updates. With the live-updates API, the app holds a CLBackgroundActivitySession, and the system shows a location indicator.

Voice over IP

voip

Be woken by PushKit VoIP pushes. Every VoIP push must be reported to CallKit as an incoming call.

Background fetch

fetch

Be launched for BGAppRefreshTask.

Background processing

processing

Be launched for BGProcessingTask.

Remote notifications

remote-notification

Be woken by background pushes (content-available: 1).

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

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:

  1. 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.

  2. Submit a request (BGAppRefreshTaskRequest or BGProcessingTaskRequest) with the same identifier. A request is a hint, not an appointment: earliestBeginDate is the earliest time, never a guaranteed one.

  3. When the system runs the task, do the work, then call setTaskCompleted(success:). Set an expirationHandler that cancels the work if time runs out.

  4. 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 progress object. The system may end tasks that appear stuck.

  • strategy = .fail makes submission fail if the system cannot start it right away; the default, .queue, waits.

  • requiredResources = .gpu asks for background GPU access, when BGTaskScheduler.supportedResources contains .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).")
    }
}

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.

  1. Run FieldNotes from Xcode on a device.

  2. Pause after submit(_:) has run, for example with a breakpoint in RefreshScheduler.scheduleNext().

  3. 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 BGTaskSchedulerPermittedIdentifiers whenever submit(_:) throws; BGTaskScheduler.Error.Code.notPermitted is the usual reason, and .unavailable means Background App Refresh is off or the app runs in the Simulator.

  • Filter the device log in Console.app by the dasd process 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

beginBackgroundTask(withName:expirationHandler:)

Yes

Yes

Not available (Mac Catalyst only)

Not available

Yes

BGTaskScheduler, BGAppRefreshTask, BGProcessingTask

Yes

Yes

Not available (Mac Catalyst only)

Not available

Yes

BGContinuedProcessingTask

New in 26

New in 26

Not available (Mac Catalyst 26)

Not available

Not available

SwiftUI backgroundTask(_:action:) with .urlSession(_:)

Yes

Yes

Yes

Yes

Yes

SwiftUI .appRefresh(_:)

Yes

Yes

Not available

Yes

Yes

SwiftUI .processingTask(_:)

New in 27

New in 27

Not available (Mac Catalyst 27)

New in 27

Not available

UIBackgroundModes capability

Yes

Yes

Not available

Yes

Yes

LongRunningIntent

New in 27

New in 27

New in 27

New in 27

New in 27

WKExtendedRuntimeSession

Not available

Not available

Not available

Yes

Not available

NSBackgroundActivityScheduler

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 _simulateLaunchForTaskWithIdentifier and _simulateExpirationForTaskWithIdentifier commands 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.