This pattern works fine. But the print
statements make me suspect that you must be attempting to watch this in the Xcode debugger console. But when testing background lifecycle events, you do not want to be attached to the Xcode debugger, as that artificially changes the app lifecycle.
That begs the question as to how one would watch for logging messages while not connected to Xcode. Personally, I use the unified logging system, namely Logger
. For example:
import SwiftUI
import os.log
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MyAppApp")
@main
struct MyAppApp: App {
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.onChange(of: scenePhase) {
guard $0 == .background else { return }
logger.debug("starting background task")
var bgTaskId: UIBackgroundTaskIdentifier = .invalid
bgTaskId = UIApplication.shared.beginBackgroundTask(withName: "ContentView") {
logger.error("background task timed out")
guard bgTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(bgTaskId)
bgTaskId = .invalid
}
Task {
await UserManager.user.save()
logger.debug("finished background task")
guard bgTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(bgTaskId)
bgTaskId = .invalid
}
}
}
}
}
class UserManager {
static let user = User()
}
struct User {
func save() async {
logger.debug("start save")
try? await Task.sleep(nanoseconds: 3_000_000_000)
logger.debug("finished save")
}
}
You could then
- install the app on the device
- quit Xcode
- fire up macOS
Console
app
- select your mobile device
- make sure
Console
is logging “info” and “debug” messages (e.g. “Action” » “Include Info Messages” and “Action” » “Include Debug Messages”)
- I personally add the “Subsystem” and “Category” columns to the log by control-clicking on the column headers in the messages and adding them
- I always filter for the salient process, subsystem, or category (so I do not get lost in the voluminous logging output) by typing some search criteria in the upper right corner
- tap “Start streaming"
- launch your app on your mobile device
- return to the Home Screen, to trigger the background process
As you can see, the save task runs correctly and all the log messages appear:

A few unrelated observations:
My code snippet uses guard
early exits, to avoid towers of braces.
I consciously avoid starting the background task in an asynchronous process. We want to avoid races between the app suspending and when the background task is created. So I create the background task before launching the asynchronous process.
I always make sure that the background task identifier is not .invalid
already before trying to end the task. You want to avoid races between the timeout and the graceful termination.
It's probably prudent to supply a name for your background task.
None of these four points are terribly critical to the question at hand, but I wanted to simply demonstrate some best practices: The key observation is to (a) test outside of Xcode; and (b) use unified logging system so that you can monitor the progress. There are other approaches to monitor the progress, but this is probably easiest.