Skip to content
Commitwise
Menu
iOS Development

App lifecycle and background tasks

Know when your iOS app is active, inactive, backgrounded, suspended or terminated, save at the right moment, finish work after the user leaves, and schedule background refreshes, processing and transfers that the system will actually run.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A user writes a long review, switches to Messages to copy a quote, and comes back an hour later to an empty text box. Another starts uploading 40 vacation photos, locks the phone, and finds 3 uploaded the next morning. iOS is strict about what apps may do when they’re not on screen, and it doesn’t warn you before it suspends or terminates your app. Code that assumes “I’ll save when the screen closes” or “the upload will keep going” loses data.

New to some of these? Open what you need, or skip ahead.

Basics:How do SwiftUI views work?

In SwiftUI, a screen is built from views: small structs that conform to View and describe what to show in a body property:

struct Greeting: View {
    let name: String
    var body: some View {
        Text("Hello, \(name)!")
    }
}

You don’t change views after they’re shown. When the data they depend on changes, SwiftUI calls body again and updates the screen to match. Views are combined by nesting (VStack, List) and adjusted with modifiers like .padding() and .font(.title).

Basics:What is async/await?

Some work takes a while: a network request, reading a file. async/await lets you write that code top to bottom while the app keeps responding: let user = try await api.loadUser() waits for the result without blocking the screen. Older APIs use callbacks instead: you pass a function that the library calls when the result is ready. The Concurrency tutorial covers it in all four languages.

Part Not started

Active, inactive, background

Think of a shop assistant. While serving a customer, they’re active. When the phone rings mid-conversation, they’re inactive: still there, but not serving. When they go to the back room, they’re in the background; and if the shop closes for the day, the lights go off without anyone asking whether they’ve finished their notes.

An iOS app’s screen (its scene) moves through the same phases. The important part: after a few seconds in the background, iOS suspends the app (freezes it), and later it may terminate it to free memory, with no further warning.

Scene phases (ScenePhase): .active (foreground, receiving events), .inactive (foreground but not receiving events: app switcher, Control Center, incoming call overlay, transitions), .background (not visible; a few seconds of execution, then suspended unless the app holds a background assertion or mode). Suspended apps receive no callbacks before being jetsammed; applicationWillTerminate is only called if the app is terminated while running in the background, not when suspended or swiped away from the switcher. The contract: persist at .background, release shared resources at .background, and restore everything from storage on launch.

struct ComposeView: View {
    @Environment(\.scenePhase) private var scenePhase
    @State private var draft = DraftStore.load()

    var body: some View {
        TextEditor(text: $draft)
            .onChange(of: scenePhase) { _, phase in
                switch phase {
                case .background:
                    DraftStore.save(draft)          // last reliable moment to save
                case .inactive:
                    break                           // a call, Control Center, the app switcher
                case .active:
                    break                           // back in front: refresh if needed
                @unknown default:
                    break
                }
            }
    }
}
PhaseWhenWhat to do
.activeIn front, the user can tapNormal work; refresh stale data on return
.inactiveBriefly interrupted: a call banner, Control Center, the app switcherPause games and timers; nothing heavy
.backgroundThe user went home or switched appsSave user data, stop location and timers you don’t need

After .background, there’s no promise the app will ever run again before it’s terminated. Whatever must survive has to be saved by then.

Spot the bugReviews that disappear

Tap the line with the bug.

struct ReviewEditor: View {
    @State private var text = DraftStore.load()

    var body: some View {
        TextEditor(text: $text)
            .navigationTitle("Your review")
            .onDisappear {
                DraftStore.save(text)
            }
    }
}

Predict the output

The app is active. The user swipes down to open Control Center, closes it, then swipes up to go to the Home Screen. Which phase changes does the app see?

Exerciseeasy

Autosave a form

A “New listing” form has a title, a price and a description. Make sure nothing the user typed is lost if they leave the app (even if iOS terminates it later), and that the form comes back filled in on the next launch.

›

You need a place on disk (UserDefaults is fine for a small draft, or a file, or SwiftData), a save when the scene goes to the background, and a load when the form is created.

SolutionTry it yourself first
struct ListingDraft: Codable, Equatable {
    var title = ""
    var price = ""
    var description = ""

    static let key = "listing-draft"

    static func load() -> ListingDraft {
        guard let data = UserDefaults.standard.data(forKey: key),
              let draft = try? JSONDecoder().decode(ListingDraft.self, from: data) else { return ListingDraft() }
        return draft
    }

    func save() {
        if let data = try? JSONEncoder().encode(self) { UserDefaults.standard.set(data, forKey: Self.key) }
    }
}

struct NewListingForm: View {
    @Environment(\.scenePhase) private var scenePhase
    @State private var draft = ListingDraft.load()

    var body: some View {
        Form {
            TextField("Title", text: $draft.title)
            TextField("Price", text: $draft.price).keyboardType(.decimalPad)
            TextField("Description", text: $draft.description, axis: .vertical)
        }
        .onChange(of: scenePhase) { _, phase in
            if phase == .background { draft.save() }
        }
        .onDisappear { draft.save() }
    }
}

To test termination: run the app from Xcode, type something, go to the Home Screen, then press Stop in Xcode (which terminates the suspended app without any callback). Launch again: the draft should be there. Clear it after the listing is published.

Launch, restoration and memory

  • Cold launches restore nothing by themselves. SwiftUI’s @SceneStorage restores small per-scene UI state (selected tab, navigation path encoded as Data) after termination; app data comes from your store.
  • Memory pressure: the app may receive memory warnings in the foreground; in the background, it’s simply terminated. Large caches should shrink on .background (and NSCache does it automatically), so the app is less likely to be the one jetsammed.
  • Multiple scenes (iPad, Mac): each scene has its own phase; app-wide work (sync) should follow the app’s overall state, not one window’s.
  • Protected data: with Data Protection, files become unreadable shortly after the device locks (NSFileProtectionComplete); background work that reads them fails. Choose the protection class per file deliberately.
  • ScenePhase: .active, .inactive (frequent, brief: app switcher, Control Center, calls), .background (seconds, then suspension). Suspended apps are terminated without callbacks; applicationWillTerminate isn’t reliable.
  • Persist at .background plus debounced autosave; onDisappear doesn’t fire on backgrounding.
  • @SceneStorage for small UI restoration; shrink caches on background; per-scene phases on iPad/Mac; file protection classes affect background reads.

Check this part

  1. When is the last reliable moment to save what the user typed?

  2. Does onDisappear run when the user switches to another app?

  3. Is applicationWillTerminate called when a suspended app is terminated by the system?

An app's scene is active (in front, receiving touches), inactive (visible but interrupted), or background (not visible, about to be suspended). Suspended apps can be terminated without any callback, so save user data when the scene goes to the background, not when a view disappears or the app quits.

Part Not started

Work that continues in the background

iOS offers a few specific ways to keep working after the user leaves, each for a different kind of job:

NeedToolHow long
Finish what you started (save, a last request)beginBackgroundTaskUsually up to about 30 seconds
Upload or download filesBackground URLSessionAs long as the transfer takes, even after termination
Refresh content now and thenBGAppRefreshTaskSeconds, when the system chooses
Heavy maintenance (cleanup, ML, sync)BGProcessingTaskMinutes, typically while charging and idle
Audio, navigation, VoIP, locationBackground modesWhile the feature is in use
@MainActor
func saveBeforeSuspension(_ draft: Draft) {
    var taskID = UIBackgroundTaskIdentifier.invalid
    taskID = UIApplication.shared.beginBackgroundTask(withName: "save-draft") {
        UIApplication.shared.endBackgroundTask(taskID)        // time's up: end it ourselves
        taskID = .invalid
    }
    Task {
        try? await api.uploadDraft(draft)                     // a short, finite job
        UIApplication.shared.endBackgroundTask(taskID)        // always end it when done
        taskID = .invalid
    }
}

beginBackgroundTask tells iOS: “I’m in the middle of something, give me a little more time”. The system grants a short window (don’t count on more than about 30 seconds). Two rules: always call endBackgroundTask when the work finishes, and end it in the expiration handler too, or iOS terminates the app for overrunning.

Transfers that survive termination

final class UploadService: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
    static let shared = UploadService()

    lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: "com.shop.uploads")
        config.isDiscretionary = false            // start now, don't wait for ideal conditions
        config.sessionSendsLaunchEvents = true    // relaunch the app when transfers finish
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    func upload(photoAt fileURL: URL, to endpoint: URL) {
        var request = URLRequest(url: endpoint)
        request.httpMethod = "PUT"
        session.uploadTask(with: request, fromFile: fileURL).resume()   // from a file, not Data
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        // record success or schedule a retry, keyed by task.taskDescription or the request URL
    }
}

A background URLSession hands the transfer to the system. It continues while your app is suspended, and even after iOS terminates it; when transfers finish, iOS wakes (or relaunches) your app to tell you. Uploads must come from a file (not data in memory), because your app may not be running to provide it.

Background sessions are system-owned: tasks continue in nsurlsessiond, the app is relaunched with application(_:handleEventsForBackgroundURLSession:completionHandler:) (SwiftUI: .backgroundTask(.urlSession(identifier))), and you must recreate the session with the same identifier and call the stored completion handler after delegate events are delivered. Only upload/download tasks from files are supported; per-task retries are yours; isDiscretionary lets the system defer to Wi-Fi and power, which is right for prefetching and wrong for user-initiated uploads.

Periodic refresh with BGTaskScheduler

  1. Declare the task identifier in Info.plist under BGTaskSchedulerPermittedIdentifiers (for example com.shop.refresh), and enable the Background fetch mode for refresh tasks.
  2. Handle it: in SwiftUI, .backgroundTask(.appRefresh("com.shop.refresh")) { await refreshCatalog() } on the WindowGroup scene.
  3. Schedule it: submit a BGAppRefreshTaskRequest with an earliestBeginDate, typically when going to the background.
  4. Reschedule every time it runs, or it runs only once.
  5. Expect the system to decide when (and whether) it runs, based on how often the user opens the app, battery and network.
@main
struct ShopApp: App {
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup { ContentView() }
            .onChange(of: scenePhase) { _, phase in
                if phase == .background { scheduleRefresh() }
            }
            .backgroundTask(.appRefresh("com.shop.refresh")) {
                scheduleRefresh()                     // next one first
                await Catalog.shared.refresh()        // cancelled automatically if time runs out
            }
    }
}

func scheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.shop.refresh")
    request.earliestBeginDate = .now.addingTimeInterval(4 * 3600)   // not before 4 hours from now
    try? BGTaskScheduler.shared.submit(request)
}

Production puzzles

Spot the bugA refresh that ran once

Tap the line with the bug.

// In application(_:didFinishLaunchingWithOptions:)
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.shop.refresh", using: nil) { task in
    Task {
        await catalog.refresh()
        task.setTaskCompleted(success: true)
    }
}

func applicationDidEnterBackground(_ application: UIApplication) {
    if !UserDefaults.standard.bool(forKey: "refreshScheduled") {
        submitRefreshRequest()
        UserDefaults.standard.set(true, forKey: "refreshScheduled")
    }
}

  • beginBackgroundTask: finite completion (~30 s), always end it, including in the expiration handler.
  • Background URLSession: file-based upload/download tasks continue after suspension/termination; relaunch via handleEventsForBackgroundURLSession / .backgroundTask(.urlSession(...)); recreate the session with the same identifier.
  • BGTaskScheduler: identifiers in Info.plist, register before launch completes, reschedule on every run, set expirationHandler, setTaskCompleted; SwiftUI .backgroundTask(.appRefresh) handles cancellation. Test via LLDB _simulateLaunchForTaskWithIdentifier.
  • Background modes (audio, location, VoIP) only for those features; App Review checks.

Check this part

  1. Which tool keeps a photo upload going after the user locks the phone?

  2. A BGAppRefreshTask runs. What must the handler do so it runs again later?

  3. What happens if a background task overruns without an expiration handler?

To finish a short job after the user leaves, ask for extra time with a background task assertion. For uploads and downloads, use a background URLSession, which the system continues even after your app is suspended or terminated. For periodic work, schedule BGTaskScheduler tasks, which run when the system decides.

Part Not started

The system decides

Scheduling a background refresh is like asking a busy receptionist to call you back “sometime after 3 o’clock”. They will, eventually, when they have a moment, and if you’re a regular caller they’ll remember you sooner. If you’ve been rude (used too much time last time) or you told them to stop calling (the user swiped your app away), they won’t.

The scheduler optimizes for battery and user experience: it predicts when the user will open the app and runs refreshes shortly before; it defers under Low Power Mode, poor network or thermal pressure; it withholds background time from apps the user rarely opens, apps that overran previous budgets, and apps the user force-quit from the switcher (until the next manual launch). Silent (content-available) pushes are coalesced and throttled. None of this is visible to the app, so correctness must never depend on a background run happening.

What you might expectWhat actually happens
“Refresh every 15 minutes”earliestBeginDate is a minimum; runs may be hours apart, or not at all
Background refresh after the user force-quits the appDoesn’t run until they open it again
A silent push runs your code every timeThrottled; several may be coalesced into one
Processing tasks run any timeUsually while charging and idle, often overnight
The same behavior in XcodeThe debugger and development builds behave differently; test on a device without Xcode attached

Predict the output

A news app schedules a BGAppRefreshTask with earliestBeginDate 15 minutes from now. The user opens the app every morning and rarely during the day. When will the refresh most likely run?

Exercisehard

Upload 200 photos reliably

A photo backup feature uploads the user’s new photos (often 200+ after a trip) to your server. Users start it and put the phone in their pocket. Requirements: uploads continue in the background and survive app termination, no photo is uploaded twice or lost, cellular data is used only if the user allowed it, and the app shows accurate progress when opened. Design it.

›

Think about where each photo’s bytes come from when your app isn’t running, how the server recognizes a retry, and where the source of truth for “what’s uploaded” lives.

SolutionTry it yourself first
  • Persistent queue: a local table (SwiftData/SQLite) of photos with state (pending, uploading(taskID), done, failed(attempts)), keyed by the photo’s local identifier plus a content hash. This, not memory, is the source of truth for progress.
  • Export to files first: for each photo, export the asset (resized/encoded as the server expects) to a file in the app’s container with an appropriate file protection class (completeUntilFirstUserAuthentication, so uploads can read it while the phone is locked).
  • Background URLSession: one upload task per file, isDiscretionary = false for user-started backups, allowsCellularAccess from the user’s setting, sessionSendsLaunchEvents = true. Set taskDescription to the photo’s id so completions map back to queue rows after relaunch.
  • Idempotent server API: PUT /photos/{clientId} (or an Idempotency-Key header with the content hash), so a retried upload after a lost response doesn’t create a duplicate.
  • Relaunch handling: recreate the session with the same identifier in .backgroundTask(.urlSession("com.app.backup")), process delegate events, update the queue, delete uploaded temp files, call the completion handler.
  • Limits: keep a bounded number of tasks in flight (e.g. 20) and enqueue more as they finish; failed tasks retry with backoff via the queue; a BGProcessingTask (requires network) picks up anything left over.
  • UI: read progress from the queue on launch; URLSession task progress for live updates while in the foreground; show a clear state when waiting for Wi-Fi.
Deep diveContinued processing for user-started workInterview favorite

Some long jobs are started by the user and they’d like them to keep going when they leave the app, like exporting a video or processing a large import. Background URLSession covers transfers and BGProcessingTask covers deferrable maintenance, but neither fits “keep exporting now”. iOS 26 adds BGContinuedProcessingTask for exactly this: the app submits it in response to a user action, the system shows progress in a Live Activity-style UI, the user can cancel it, and the app must report progress or the task is stopped. Before that, the options were beginBackgroundTask (seconds) or splitting the work so it can resume on the next launch.

  • The scheduler optimizes for battery and predicted usage: earliestBeginDate is a floor, runs cluster before predicted launches, are deferred in Low Power Mode/poor conditions, and stop after a force-quit until the next launch. Silent pushes are throttled and coalesced.
  • Correctness must not depend on background runs: refresh on launch, show data age, keep a persistent work queue as the source of truth.
  • Long user-initiated work: background URLSession for transfers, BGProcessingTask for deferrable work, BGContinuedProcessingTask (iOS 26) for user-started processing with visible progress.
  • Test on devices without the debugger; simulate launches via LLDB.

Check this part

  1. What does earliestBeginDate on a refresh request mean?

  2. The user swipes your app away in the app switcher. Will scheduled background refreshes run?

  3. Where should "which photos are uploaded" live for a background backup?

Background execution is a budget the system manages: it runs refreshes based on how often the user opens your app, battery, network and Low Power Mode, never after the user force-quits the app, and throttles silent pushes. Design features so a missed background run is harmless.

Key takeaways

  • An app is active, inactive or in the background; after that it can be suspended and terminated without warning.
  • Save user data when the scene goes to the background, not in onDisappear or on termination.
  • Use beginBackgroundTask to finish short work, a background URLSession for transfers, and BGTaskScheduler for periodic refreshes.
  • The system decides when background work runs; the app must still work if it never does.
  • Persist at .background; restore from storage and @SceneStorage; applicationWillTerminate isn’t a save point.
  • End background task assertions; set expiration handlers; reschedule BG tasks on every run.
  • Background sessions need file-based tasks, idempotent servers and a persistent queue.
  • Treat background execution as a best-effort budget and test on real devices without the debugger.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.