Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 096 / 100

App lifecycle in SwiftUI: a hands-on guide for engineers

scenePhase and app events.

Expona Admin7 min read

App lifecycle in SwiftUI: a hands-on guide for engineers

Managing app state across different lifecycle phases is fundamental to shipping robust iOS applications. When a user backgroundsyour app, closes it, or switches between multiple windows, your code needs to respond correctly—saving work, cleaning up resources, and restoring state seamlessly. Understanding how to use scenePhase and react to app events is what separates production-grade apps from those that frustrate users with lost data and stale content.

Understanding scenePhase in SwiftUI

The @Environment(\.scenePhase) property wrapper is your gateway to lifecycle awareness. It gives you three possible states: .active (app is running in foreground), .inactive (transitional state, like during app switcher), and .background (app is backgrounded but still alive).

Here's the foundational pattern we use across our team's production apps:

swift
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 now active")
            case .inactive:
                print("App is transitioning")
            case .background:
                print("App moved to background")
            @unknown default:
                break
            }
        }
    }
}

Notice we're watching scenePhase at the app level using .onChange. This gives us a single point where we can coordinate behavior across the entire application. You could also track it within individual views, but centralizing it reduces bugs from multiple handlers firing simultaneously.

Save data when moving to background

The most critical use case: persisting user work before the OS might kill your process. When scenePhase transitions to .background, you have limited time—typically a few seconds—to write to disk.

Let's walk through a real scenario. Imagine we're building a note-taking app where users draft content that shouldn't be lost:

swift
class NoteDataManager: ObservableObject {
    @Published var currentNote: Note = Note()
    
    func saveToDisk() {
        do {
            let encoder = JSONEncoder()
            let data = try encoder.encode(currentNote)
            let url = FileManager.default.documentsDirectory
                .appending(component: "draft.json")
            try data.write(to: url)
            print("Note saved to disk")
        } catch {
            print("Failed to save: \(error)")
        }
    }
    
    func loadFromDisk() {
        let url = FileManager.default.documentsDirectory
            .appending(component: "draft.json")
        guard let data = try? Data(contentsOf: url) else { return }
        do {
            let decoder = JSONDecoder()
            currentNote = try decoder.decode(Note.self, from: data)
        } catch {
            print("Failed to load: \(error)")
        }
    }
}

@main
struct NotesApp: App {
    @Environment(\.scenePhase) var scenePhase
    @StateObject var dataManager = NoteDataManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(dataManager)
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .background {
                dataManager.saveToDisk()
            }
        }
    }
}

Notice the pattern: we only save when entering .background, not constantly. Constant disk writes kill battery life and wear SSDs. We batch updates into lifecycle events. Also, keep your save operation synchronous and fast—don't kick off async network calls here, because the system might suspend your process immediately after background state begins.

For apps using Core Data, you'd call save() on your NSManagedObjectContext instead:

swift
.onChange(of: scenePhase) { oldPhase, newPhase in
    if newPhase == .background {
        let context = PersistenceController.shared.container.viewContext
        if context.hasChanges {
            try? context.save()
        }
    }
}

Refresh when returning to active

When the app returns from background, content might be stale. A user might have logged out on another device, server data could have changed, or your local cache might be outdated. The .active phase is your signal to refresh.

Here's how we handle this in a real e-commerce context:

swift
class CartManager: ObservableObject {
    @Published var cartItems: [CartItem] = []
    @Published var isRefreshing = false
    
    func refreshCart() async {
        isRefreshing = true
        defer { isRefreshing = false }
        
        do {
            let items = try await APIClient.fetchCart()
            await MainActor.run {
                self.cartItems = items
            }
        } catch {
            print("Cart refresh failed: \(error)")
        }
    }
}

@main
struct ShopApp: App {
    @Environment(\.scenePhase) var scenePhase
    @StateObject var cartManager = CartManager()
    
    var body: some Scene {
        WindowGroup {
            CartView()
                .environmentObject(cartManager)
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .active && oldPhase == .background {
                Task {
                    await cartManager.refreshCart()
                }
            }
        }
    }
}

Key detail: we check that the old phase was .background before refreshing. This prevents unnecessary refreshes during app launch (when oldPhase is .inactive). The guard condition oldPhase == .background ensures we only refresh when actually returning from backgrounding, not on every state transition.

Use Task to dispatch async work to the main thread. The system will keep your app alive long enough to complete reasonable network calls, though you should target sub-second latency for smooth UX.

Scenes for multiple windows

iPad and Mac apps often support multiple windows of the same content. SwiftUI's Scene architecture makes this pattern straightforward. Instead of WindowGroup, you can define multiple scene types or add custom window handling.

Consider a document editor where each window might show a different file:

swift
@main
struct DocumentApp: App {
    @Environment(\.scenePhase) var scenePhase
    
    var body: some Scene {
        WindowGroup("Main Window") {
            ContentView()
        }
        
        #if os(macOS)
        Window("Settings", id: "settings") {
            SettingsView()
        }
        #endif
    }
}

Each scene has its own lifecycle. If you set up lifecycle handlers at the app level, they fire for all scenes. However, if you need per-window behavior, observe scenePhase within individual views or ViewModels tied to that scene.

A more practical example: tracking which window was most recently active:

swift
class WindowStateManager: ObservableObject {
    @Published var activeWindowPhase: ScenePhase = .inactive
}

@main
struct MultiWindowApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modifier(TrackScenePhaseModifier())
        }
    }
}

struct TrackScenePhaseModifier: ViewModifier {
    @Environment(\.scenePhase) var scenePhase
    @StateObject var windowManager = WindowStateManager()
    
    func body(content: Content) -> some View {
        content
            .onChange(of: scenePhase) { oldPhase, newPhase in
                windowManager.activeWindowPhase = newPhase
            }
    }
}

This approach lets each window track its own lifecycle independently while sharing data through an environment object if needed.

Common mistakes

  • Forgetting to save before background. Apps get suspended quickly; if you don't save synchronously when entering .background, you'll lose data. Async saves often don't complete in time.
  • Refreshing on every state transition. Check that you're transitioning from background to active, not from inactive to active. Otherwise you refresh unnecessarily at app launch.
  • Expensive operations during lifecycle events. Don't perform heavy computation, large file reads, or multiple network calls synchronously in lifecycle handlers. Dispatch heavy work asynchronously and show loading states.
  • Ignoring multiple scenes. If your app supports multiple windows, remember that each scene has its own lifecycle. Global state changes from one window's refresh might conflict with another window's state.
  • Not handling the inactive phase. The .inactive phase is brief but real. Don't assume the app jumps directly from active to background—audio, widgets, and notifications might need special handling during this transition.

Key takeaways

  • Use @Environment(\.scenePhase) and .onChange at the app level to coordinate lifecycle behavior across your entire application.
  • Save data synchronously when moving to .background; the OS may kill your process shortly after, so avoid async operations.
  • Refresh stale data when returning to .active from .background, but guard your condition to avoid refreshing unnecessarily at launch.
  • For multi-window apps, track lifecycle at both the app and per-window level depending on your architecture needs.
  • Test lifecycle transitions thoroughly using Xcode's simulator—backgrounding shortcuts, system memory pressure simulations, and multi-scene scenarios all matter in production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles