On this page
iOS apps do not get to decide when users are interrupted.
The user starts a transfer, edits a long form, attaches three photos, switches to Messages, answers a call, loses network, comes back later, and expects the app to still know what was happening. That expectation is reasonable. The operating system may terminate your process. The user still thinks they were in the middle of a task.
State restoration is not about recreating every object that happened to exist in memory. That is usually how teams build a fragile little museum of view models and stale references. The useful version is narrower: preserve enough state to let the user continue an important flow without repeating work or losing trust.
1. Decide which flows deserve restoration
Not every screen needs to come back exactly as it was.
A settings detail screen can often reopen at the root. A search result list can be rebuilt. A marketing screen can disappear and nobody sensible will file a ticket.
The flows that deserve real restoration are the ones where interruption has a cost:
- multi-step onboarding
- checkout, booking, payment, or subscription flows
- long forms and user-generated drafts
- media uploads and document imports
- authentication or permission handoffs
- offline edits waiting to sync
- support messages, reports, and anything users write by hand
Start by naming those flows explicitly. For each one, define what must survive:
- The user's entered data.
- The current step or navigation target.
- Any pending work already started.
- The validation or server state needed to resume safely.
- The recovery UI the user should see on return.
If you cannot name the restoration contract for a flow, the implementation will drift into saving random UI state and hoping the app wakes up in a good mood. It rarely does.
2. Separate navigation state from domain progress
Navigation is not the same thing as progress.
A SwiftUI NavigationPath can tell you where the user was in the interface. It cannot tell you whether a draft was saved, whether an upload already started, whether a payment intent expired, or whether the server accepted step three of a five-step workflow.
Treat those as separate layers:
- Navigation state: the route the app should reopen.
- Draft state: local user input that has not been finalized.
- Workflow state: durable progress through a product flow.
- Job state: background or retryable work that may continue after interruption.
That separation keeps restoration honest. The app can decide to restore the draft but not the exact modal stack. It can resume a background upload but show a safer review screen instead of dropping the user into a stale confirmation view.
A useful rule: restore domain progress first, then choose the UI that best represents it now.
Do not force the UI to become a time machine.
3. Persist drafts before users notice they need saving
A draft should not live only in a view model.
If the user has typed enough text, selected enough options, or attached enough files that losing the work would be annoying, the app should already be writing a local draft. Waiting for the final submit button is optimistic engineering. Optimism is not a persistence strategy.
For production apps, drafts need a small but clear model:
- a stable draft identifier
- the flow or feature that owns it
- the user-entered fields
- attachment references, not just in-memory images
- validation status where useful
- timestamps for creation and last edit
- a schema version for migration
Write incrementally. Debounce text updates if needed, but do not wait until the app backgrounds. Background time is limited, termination is real, and users have a gift for finding the one path that did not flush to disk.
This is especially important for SwiftUI forms. The view can be rebuilt at any time. The draft should not care. It should sit in SQLite, SwiftData, Core Data, or another local store and let the UI become disposable.
That is the correct hierarchy: user work is durable; views are rented furniture.
4. Restore routes with stable identifiers, not objects
A common restoration mistake is trying to preserve too much of the runtime shape.
Do not store view models, closures, service instances, temporary indexes, or entire decoded API responses as restoration state. Store stable references the app can resolve after launch.
Good restoration targets look like this:
editProfileDraft(id: DraftID)bookingFlow(id: BookingSessionID, step: StepID)supportThread(id: ThreadID)uploadReview(jobID: UploadJobID)documentDetail(id: DocumentID)
Weak targets look like this:
- array index
4 - selected object serialized from an old API response
- a view model snapshot
- a Boolean pile called
isShowingSomething - a route that depends on data the app no longer owns
On launch, resolve the route against current app state. If the record still exists and the user still has access, restore it. If it does not, fall back to a clear recovery screen.
This extra resolution step is not busywork. It is where you avoid reopening deleted records, expired sessions, or screens that no longer make sense after the app has been away for six hours.
5. Make pending work resumable and idempotent
The hardest restoration bugs usually involve work that started before interruption.
Uploads, imports, sync writes, subscription changes, and checkout flows cannot be treated like normal UI state. The app needs to know whether the operation did not start, started locally, reached the server, partially completed, failed, or needs user review.
Represent that as a job or workflow record, not as a spinner in memory.
A reliable pending-work model usually includes:
- A stable local job ID.
- The operation type and required inputs.
- A server idempotency key where mutation is involved.
- Current status: pending, running, waiting for network, failed, completed, cancelled.
- Retry count and last error.
- Enough result data to show the user what happened.
Idempotency matters. If the app retries after a termination, the server should not create two bookings, charge twice, send two messages, or upload duplicate records because the client had a dramatic afternoon.
For file and media work, store durable file references before starting the operation. Temporary URLs, in-memory UIImage instances, and picker-provided handles can disappear. If the upload matters, move the asset into app-owned storage, record it, and then process it.
6. Resume with a product decision, not a surprise
Restoration should help the user continue. It should not trap them in old context.
When the app launches after interruption, choose the recovery experience deliberately:
- Reopen the exact flow if the state is fresh and safe.
- Show a "Continue draft" card if the user may not remember the context.
- Present a review screen if the operation has consequences.
- Show completion if the pending work finished while they were away.
- Show a specific failure state if the app needs input before retrying.
The worst version silently jumps the user deep into a flow with stale assumptions. The second-worst version throws everything away and pretends nothing happened.
A good restoration UI is boring and explicit:
"You have an unfinished support message from 14:32. Continue editing or discard it."
"Your document upload finished. Review before sending."
"The booking session expired. Your details were saved; choose a new time slot."
That language builds trust because it makes the app's memory visible. Users do not need magic. They need proof that the app did not lose the plot.
7. Handle expiration and schema changes directly
Restored state ages.
A draft from ten minutes ago is probably useful. A checkout session from last month is probably not. A navigation path from a previous app version may point to a route that no longer exists. A locally saved form may use field names the current release has migrated.
Every restoration record should have enough metadata to make a decision:
- creation and last-updated timestamps
- app version or schema version
- owning user or account ID
- relevant server session expiry
- feature flag or entitlement requirements
Then define clear policies:
- migrate drafts when the schema changes
- discard or archive expired workflow sessions
- reopen only records that still belong to the active account
- fall back safely when feature flags changed while the app was closed
- explain when a flow cannot be resumed
Silent failure is expensive here. If the app cannot restore something important, say why and preserve what can still be preserved.
"This booking expired, but your passenger details were saved" is much better than dumping the user at the home screen like nothing happened.
8. Test restoration by killing the app at rude moments
You cannot test restoration by taking the happy path and pressing Home once.
Test it where apps actually fail:
- Kill the app while editing a long draft.
- Kill it during an upload after the local file is selected but before the request completes.
- Background it during a payment, auth, or permission handoff.
- Switch accounts with stale restoration records present.
- Install a new build with an older draft schema on disk.
- Disable network, resume, then restore connectivity.
- Restore into a record that was deleted or changed on another device.
These scenarios should be part of release testing for flows that handle meaningful user work. They do not all need elaborate UI automation. Some can be covered with workflow-store unit tests and a few brutal manual scripts.
The important part is forcing the app to prove the contract. If the contract is "we preserve drafts," kill the app before submit and verify the draft returns. If the contract is "uploads resume," interrupt the upload and verify the job does not duplicate or vanish.
State restoration is not a visual polish item. It is part of reliability.
9. Keep the restoration surface small
The safest restoration system is the one with a small surface area.
Do not persist every route, tab, filter, sheet, alert, and transient control because it feels thorough. That creates brittle state that breaks every time the UI changes. Persist what protects user work and product correctness.
A sane restoration checklist is short:
- Which flows are worth restoring?
- What user work must be durable?
- What route identifier should reopen the flow?
- What pending jobs need to resume or report status?
- What state expires?
- What does the user see if restoration fails?
That checklist catches the serious problems without turning restoration into a second app architecture.
The goal is not to make interruption invisible. The goal is to make interruption survivable. If the user can return, understand what happened, and continue without losing work, the app has done its job.