- Published on
Detecting When App Enters Background and Saving State in SwiftUI
- Authors

- Name
- Mick MacCallum
- @0x7fs
When users switch apps or lock their screen, your app moves to the background. At this moment, you should save important state—unsaved drafts, user progress, scroll positions—so users don't lose work if iOS terminates your app while backgrounded.
SwiftUI provides two approaches for detecting lifecycle changes: the modern scenePhase environment value and traditional UIApplication notifications. Each has its use case, and understanding both gives you complete control over state persistence.
The Problem: Apps Can Be Terminated
iOS may terminate backgrounded apps to free memory. When users return, your app cold-starts. Without state saving, users lose:
- Unsaved draft content
- Navigation state (which screen they were on)
- Form data
- Scroll positions
- Selected tabs or filters
This creates a frustrating experience where the app feels "forgetful."
Solution 1: Using ScenePhase (Modern SwiftUI)
iOS 14+ introduced scenePhase, a SwiftUI-native way to track app lifecycle:
import SwiftUI
@main
struct MyApp: App {
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
print("App is active")
// App is in foreground and receiving events
case .inactive:
print("App is inactive")
// Transitioning between foreground and background
// Or Siri/Control Center is showing
case .background:
print("App is in background")
// Save important state here
saveAppState()
@unknown default:
break
}
}
}
func saveAppState() {
// Save critical data
print("Saving app state...")
}
}
Understanding Scene Phases
- active: App is running in foreground and receiving user events
- inactive: Temporary state during transitions (app switching, incoming calls, Control Center)
- background: App is no longer visible, may be terminated by iOS
The inactive phase happens briefly before background, giving you a chance to prepare.
Solution 2: Using UIApplication Notifications
For more granular control, use UIApplication notifications:
import SwiftUI
import Combine
class AppLifecycleObserver: ObservableObject {
private var cancellables = Set<AnyCancellable>()
init() {
// App will resign active (about to lose focus)
NotificationCenter.default
.publisher(for: UIApplication.willResignActiveNotification)
.sink { _ in
print("App will resign active")
self.prepareForBackground()
}
.store(in: &cancellables)
// App did enter background
NotificationCenter.default
.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { _ in
print("App entered background")
self.saveState()
}
.store(in: &cancellables)
// App will enter foreground
NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.sink { _ in
print("App will enter foreground")
self.prepareForForeground()
}
.store(in: &cancellables)
// App did become active
NotificationCenter.default
.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { _ in
print("App became active")
self.restoreState()
}
.store(in: &cancellables)
}
func prepareForBackground() {
// Pause timers, animations
}
func saveState() {
// Save critical data
}
func prepareForForeground() {
// Refresh data that may be stale
}
func restoreState() {
// Resume timers, animations
}
}
@main
struct MyApp: App {
@StateObject private var lifecycleObserver = AppLifecycleObserver()
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Practical Example: Saving Draft Content
Here's a complete example saving a draft note:
import SwiftUI
class DraftManager: ObservableObject {
@Published var draftText: String = ""
private let draftKey = "savedDraft"
init() {
// Load draft on launch
loadDraft()
}
func saveDraft() {
UserDefaults.standard.set(draftText, forKey: draftKey)
print("Draft saved: \(draftText)")
}
func loadDraft() {
draftText = UserDefaults.standard.string(forKey: draftKey) ?? ""
print("Draft loaded: \(draftText)")
}
func clearDraft() {
draftText = ""
UserDefaults.standard.removeObject(forKey: draftKey)
}
}
struct DraftEditorView: View {
@StateObject private var draftManager = DraftManager()
@Environment(\.scenePhase) var scenePhase
var body: some View {
VStack {
Text("Auto-saves when you leave the app")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top)
TextEditor(text: $draftManager.draftText)
.border(Color.gray.opacity(0.3), width: 1)
.padding()
if !draftManager.draftText.isEmpty {
Button("Clear Draft") {
draftManager.clearDraft()
}
.padding(.bottom)
}
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .background {
draftManager.saveDraft()
}
}
}
}
Saving Complex State with Codable
For structured data, use Codable:
struct AppState: Codable {
var selectedTab: Int
var searchText: String
var filters: [String]
var lastViewedItemID: String?
}
class StateManager: ObservableObject {
@Published var appState: AppState
private let stateKey = "appState"
init() {
if let data = UserDefaults.standard.data(forKey: stateKey),
let decoded = try? JSONDecoder().decode(AppState.self, from: data) {
appState = decoded
} else {
appState = AppState(
selectedTab: 0,
searchText: "",
filters: [],
lastViewedItemID: nil
)
}
}
func save() {
if let encoded = try? JSONEncoder().encode(appState) {
UserDefaults.standard.set(encoded, forKey: stateKey)
}
}
}
struct MainView: View {
@StateObject private var stateManager = StateManager()
@Environment(\.scenePhase) var scenePhase
var body: some View {
TabView(selection: $stateManager.appState.selectedTab) {
HomeView()
.tag(0)
.tabItem { Label("Home", systemImage: "house") }
SearchView(searchText: $stateManager.appState.searchText)
.tag(1)
.tabItem { Label("Search", systemImage: "magnifyingglass") }
SettingsView()
.tag(2)
.tabItem { Label("Settings", systemImage: "gear") }
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .background {
stateManager.save()
}
}
}
}
Background Tasks
If you need to complete a task before iOS suspends your app, request background time:
import UIKit
class BackgroundTaskManager {
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func beginBackgroundTask() {
backgroundTask = UIApplication.shared.beginBackgroundTask {
// Task took too long, clean up
self.endBackgroundTask()
}
}
func endBackgroundTask() {
guard backgroundTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
func saveImportantData() async {
beginBackgroundTask()
defer {
endBackgroundTask()
}
// Perform save operation
// You have about 30 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000)
print("Data saved successfully")
}
}
struct ContentView: View {
@Environment(\.scenePhase) var scenePhase
private let taskManager = BackgroundTaskManager()
var body: some View {
Text("Content")
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .background {
Task {
await taskManager.saveImportantData()
}
}
}
}
}
Combining Both Approaches
Use both scenePhase and notifications for different purposes:
@main
struct MyApp: App {
@Environment(\.scenePhase) var scenePhase
@StateObject private var lifecycleObserver = AppLifecycleObserver()
@StateObject private var dataManager = DataManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(dataManager)
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .background:
// Critical state saving
dataManager.saveAllData()
case .active:
// Refresh potentially stale data
Task {
await dataManager.refreshIfNeeded()
}
default:
break
}
}
}
}
Handling Navigation State
Save and restore navigation state:
class NavigationState: ObservableObject {
@Published var path: NavigationPath
private let pathKey = "navigationPath"
init() {
// Load saved path
if let data = UserDefaults.standard.data(forKey: pathKey),
let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data),
let path = NavigationPath(decoded) {
self.path = path
} else {
self.path = NavigationPath()
}
}
func save() {
guard let representation = path.codable,
let encoded = try? JSONEncoder().encode(representation) else { return }
UserDefaults.standard.set(encoded, forKey: pathKey)
}
}
struct AppWithNavigation: View {
@StateObject private var navState = NavigationState()
@Environment(\.scenePhase) var scenePhase
var body: some View {
NavigationStack(path: $navState.path) {
HomeView()
.navigationDestination(for: String.self) { item in
DetailView(item: item)
}
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .background {
navState.save()
}
}
}
}
ScenePhase vs Notifications: When to Use Each
Use scenePhase when:
- You want SwiftUI-native lifecycle handling
- You're working at the Scene or Window level
- You need simple active/background detection
- You want cleaner, more declarative code
Use UIApplication notifications when:
- You need more granular lifecycle events
- You're working with UIKit components
- You need backward compatibility with older iOS versions
- You want to observe from anywhere (not just views)
Use both when:
- You need comprehensive lifecycle coverage
- Different parts of your app need different approaches
- You're migrating from UIKit to SwiftUI gradually
Best Practices
Do:
- Save state in
.backgroundphase (scenePhase) ordidEnterBackgroundNotification - Keep save operations fast (< 5 seconds)
- Save incremental changes as users work, not just on background
- Test by backgrounding your app during development
- Use
UserDefaultsfor small data, files for large data
Don't:
- Perform long-running operations during background transition
- Wait until background to save critical data (save continuously)
- Block the main thread during save
- Forget to test state restoration on cold launch
- Save sensitive data without encryption
Testing State Saving
Test thoroughly:
- Background the app mid-edit
- Terminate from app switcher (swipe up)
- Cold launch and verify state restored
- Background during navigation, verify position restored
- Test with low memory conditions
Understanding app lifecycle and implementing proper state saving makes your SwiftUI app reliable and user-friendly, ensuring users never lose their work.
Continue Learning
Adding an App Delegate to a SwiftUI App
Learn how to integrate UIKit's App Delegate into your SwiftUI app for handling app lifecycle events and system callbacks.
Implementing Undo/Redo in SwiftUI with UndoManager
Learn how to implement undo/redo functionality in SwiftUI using UndoManager, including environment injection, registering actions, and creating undo buttons for professional editing experiences.
Detect Successful Share Sheet Completion in SwiftUI
Learn how to detect when a user successfully shares content using a share sheet in SwiftUI by wrapping UIActivityViewController.
