iOS – The App Lifecycle and What a Phone Does to You

August 31, 20268 min readUpdated 9/18/2026

A browser tab lives until somebody closes it. Your app does not get that courtesy: it can be suspended mid-sentence, and terminated without warning while the customer is looking at something else entirely.

This lesson is what actually happens, the two places it changes how you write code, and the startup ordering that is load-bearing rather than tidy.

The states

An iOS app moves between five, and three of them matter to you.

Active — on screen, receiving events. Inactive — on screen but not receiving them: mid app-switcher, a call arriving, Control Centre pulled down. Background — off screen, running briefly on borrowed time. Suspended — in memory, executing nothing. And then terminated, which from your process's point of view does not happen at all: it simply stops, with no callback and no chance to save.

That last point is the one to internalise. You do not get told you are being killed. The system reclaims memory by terminating suspended apps, and a suspended app has already stopped running. Anything you were going to save had to be saved before you were suspended.

scenePhase

SwiftUI reports the transitions through the environment:

    @Environment(\.scenePhase) private var scenePhase

Three values — .active, .inactive, .background — and one place to observe them:

        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .background || newPhase == .inactive {
                environment.cart.flushPendingWrites()
            }
        }

.inactive is included alongside .background on purpose. It arrives first, while the app is still running normally, so the write starts during the app-switcher animation rather than in the much shorter and less certain window after it.

The mobile-only problem

The demo app's cart is debounced: three taps on "+" become one request 300 ms after the last one. That is correct on the web, where a tab stays alive and the timer always gets to fire.

On a phone it is a bug waiting for the right customer. Add a pizza, immediately switch apps, and the system may suspend the process before the debounce elapses — and may then terminate it. The basket is gone, and nothing anywhere logged a failure, because nothing failed.

    func flushPendingWrites() {
        guard isHydrated else { return }
        persistTask?.cancel()
        persistTask = Task { await self.persist() }
    }

Cancel the pending debounce, write immediately. Two details are worth naming.

The guard isHydrated is the most important line in the file. Writing before the saved cart has loaded would overwrite it with an empty one — the worst bug this type can have, and one that would only reproduce for customers who already had a basket.

And the task is deliberately not awaited by the caller. scenePhase changes synchronously and the system gives a short, unguaranteed window afterwards; this races to use it rather than pretending it can block. If the work genuinely needs longer, that is what beginBackgroundTask is for — and it is worth knowing it exists and that the extra time is measured in seconds, not minutes.

What "saving" means, defined once

The flush and the debounce both call the same method, so there is one implementation of what a save is rather than two that drift:

    private func persist() async {
        do {
            let identifier: UUIDString
            if let cartID {
                identifier = cartID
            } else {
                // Do not create a cart row just because someone opened the app and browsed.
                guard !state.items.isEmpty else { return }
                let created = try await repository.createCart()
                identifier = created.id
                cartID = identifier
                identifierStore.save(identifier)
            }

            _ = try await repository.replaceCart(id: identifier, with: writeRequest())
        } catch {
            guard !ErrorPresenter.isCancellation(error) else { return }
            AppLog.cart.error("Could not persist the cart: \(error.localizedDescription, privacy: .public)")
        }
    }

Two lifecycle-shaped decisions in there. A cart row is not created until there is something in it, so opening the app and browsing away leaves nothing behind. And a failed save is logged rather than surfaced — the cart still works for this session, it simply will not survive a relaunch, and a toast on every tap would be a worse experience than the failure it reports.

The write itself is an idempotent replacement of the whole cart rather than a sequence of add and remove calls. That is what makes the flush safe to fire at an awkward moment: a retry cannot double an item, and the device never has to reconcile a partial sequence of writes it is unsure landed.

Launch, and the gate an async read forces

The second place the lifecycle changes your code is the first frame.

On the web the session token comes out of localStorage synchronously, so the very first render already knows whether anyone is signed in. Here it is in the Keychain and reading it is asynchronous, so for a moment the app genuinely does not know.

Render the signed-out UI during that gap and the Profile tab flashes "Sign in" and then swaps — and a screen gated on authentication would bounce a signed-in customer out of the tab they just opened.

        Group {
            if auth.isRestoringSession {
                LaunchPlaceholder()
            } else {
                tabs
            }
        }

The placeholder is deliberately the launch screen's own content rather than a spinner. Matching what the system already drew makes the transition into the app invisible, instead of a flash of a second, different loading state.

Restoring is not the same as having a token

    func restoreSession() async {
        defer { isRestoringSession = false }

        guard await tokenStore.currentToken() != nil else { return }

        do {
            user = try await repository.currentUser()
        } catch {
            AppLog.auth.info("Stored session could not be restored; clearing the token.")
            await tokenStore.clear()
            user = nil
        }
    }

The presence of a token proves nothing — it may have expired or been revoked — so /api/auth/me is the source of truth. A failure drops the token rather than leaving a dead one behind that makes every later authenticated call fail with a confusing 401.

defer guarantees the flag clears on every path, including the throwing one. Without it, a failed restore leaves the app on its launch placeholder forever.

The startup sequence is ordered, deliberately

        .task {
            await auth.restoreSession()
            await menu.reload()
            await cart.hydrate()
        }

Three awaits, and the order is load-bearing. The session is restored first because an authenticated request needs the token. The menu is loaded next. The cart hydrates last, because a saved line holds only identifiers — its price and crust surcharge come from the catalogue, so hydrating first would produce a basket full of zero-priced lines.

Worth noticing what this costs: the three run in sequence, so launch is as slow as all of them. That is the right trade here because each genuinely depends on the last, but it is the kind of thing to measure rather than assume — if the menu did not feed the cart, those two would be async let.

.task also cancels automatically if the view goes away, so there is no onAppear plus manual cancellation to get wrong.

Rebuilding a cart from identifiers

Hydration is the other half of the round trip, and it is where the ordering rule above becomes concrete:

            let items = cart.items.map { line -> CartItem in
                let product = catalogue.product(id: line.productId)
                let crust = catalogue.crust(id: line.crustId)

                return CartItem(
                    lineID: UUID(),
                    productID: line.productId,
                    productName: line.productName,
                    productType: line.productType,
                    imageURL: product?.imageUrl,
                    size: line.size,
                    basePrice: product?.price(for: line.size) ?? 0,

The stored line carries identifiers and quantities; the price comes from the catalogue. Hydrate before the menu has loaded and every basePrice falls through to that ?? 0 — a basket that looks right and totals nothing.

The other decision there is generating a fresh lineID rather than reusing the server's. A line's identity on the device is "this configuration", not a database row, which is what lets two identical pizzas added separately collapse into one line with quantity two.

When the saved cart is gone

            AppLog.cart.info("Saved cart could not be loaded; forgetting the stored identifier.")
            identifierStore.clear()
            self.cartID = nil

Deleted server-side, or a stale id left over from pointing the app at a different environment. Forgetting it beats leaving the device aimed at a cart that will never load — which would fail identically on every launch, forever, for that one customer.

Cold launch, warm launch, and what you can control

A cold launch starts a process. A warm one resumes a suspended app and is nearly instant. You mostly control the first, and the rule is that nothing expensive belongs before the first frame — @main should build a dependency graph and get out of the way, with the network work happening in a .task the UI is already visible for.

The demo app's graph construction is a handful of struct initialisers and one Keychain read, which is why the launch gate is measured in frames rather than seconds.

State restoration

Because the system terminates apps routinely, a customer who returns after an hour is often starting a fresh process. Restoring what they were doing is what makes that invisible.

The demo app restores the important half — the cart, from the server — and deliberately not the navigation position. That is a reasonable line for a shopping app: the basket is the customer's work and the screen they were on is not. Where you want the whole thing, @SceneStorage persists small values per scene automatically, and the reason the router in lesson 5 keeps navigation as an array of values rather than as implied view state is that an array of values is something you could store there.

Push notifications and background work

Two things the demo app does not do, worth knowing the shape of. A silent push can wake a suspended app to fetch something, and BGTaskScheduler can run periodic work the system decides when to grant. Both are ways of getting execution time you would not otherwise have, and both are granted at the system's discretion rather than yours — code that assumes a background task ran on schedule is code that will be wrong for somebody with Low Power Mode on.

A checklist

  • Flush anything debounced on .inactive.
  • Never assume a callback before termination. There is not one.
  • Gate the first frame on anything asynchronous that decides what the UI should be.
  • Verify the token, do not trust its presence.
  • Test it for real: background the app from the app switcher, then swipe up to kill it, then relaunch. That sequence is the only way to find these bugs, and it takes ten seconds.

Next

The cart survives everything a phone can do to it. The next lesson turns it into an order and takes money for it — Stripe's payment sheet, quarantined behind a protocol so checkout stays testable.