Senior / Staff iOS — interview prep
Data persistence
storage · modeling · migrations · sync · scale · concurrency · SwiftData
KeychainCachesNormalizedMigrationsOutboxIdempotentModelContextMainActor
01
Choosing storage
Q1Where things go
- Core Data / SwiftData — relational app data, queries, relationships.
- UserDefaults — small toggles and preferences only.
- Files — large media and blobs; metadata + path in the DB.
- Keychain — tokens, keys, secrets (not preferences).
iOS directories
- tmp / Library/Caches — regenerable data; OS may purge under pressure.
- Documents / Application Support — durable until user deletes app; define cleanup.
02
Modeling for real queries
Q2i
Model for screens and fetches you actually run — feed, detail, search, sync — not a theoretical ERD alone.
Defaults
- Stable IDs everywhere (server or app-generated).
- Normalize first — separate entities + relationships.
- Denormalize only for proven hot reads (counts, last activity) with update rules.
- Separate canonical persistence from UI-only formatting.
Design for change
- Tolerate partial loads and later enrichment.
- Plan indexes, pagination, and common sorts early.
- Make delete/ownership rules explicit (cascade, orphans, media).
- Keep migration path in mind — optional fields, versioning.
03
Migrations in production
Q3- Treat upgrades as release-critical: test from real old stores (N−2, N−1), not only fresh install.
- Prefer lightweight steps when possible; use custom migration when semantics change (split/merge fields).
- Keep steps idempotent where you can; add telemetry on failure and duration.
04
Offline & sync
Q4Layering
- Local store = UI source of truth; sync reconciles with remote.
- Outbox for pending mutations — retry, backoff, crash recovery.
- Idempotent operations + stable op IDs so retries don’t double-apply.
Conflicts
- Pick policy per entity: server-wins, last-write, or merge rules.
- Track versions / etag / updatedAt to detect clashes.
Outbox shape (concept)
struct PendingMutation: Codable {
let opId: UUID
let entityId: String
let type: String // create / update / delete
var attemptCount: Int
}
05
Scale & performance
Q5| Symptom | Try |
|---|---|
| Slow list loads | Indexes, narrower fetches, pagination, avoid over-fetching relationships |
| Slow writes | Batch inserts/updates, reduce save frequency in hot loops, move transforms off main |
| Memory spikes | Page data, cap caches, profile with realistic row counts |
06
Thread safety (Core Data / SQLite style)
Q6- One writer path (serial context / queue) for mutable store; controlled readers per stack rules.
- Don’t pass managed objects across threads — pass object IDs / DTOs, refetch in owning context.
07
Normalized vs denormalized
Q7Normalized
One fact in one place; related rows linked by ID — e.g. user name on User, posts reference authorId.
Denormalized
Duplicate or pre-aggregate for read speed — e.g. commentCount on Post — with explicit rules to keep fields consistent.
08
SwiftData & data races
Q8!
Own the ModelContext — don’t share one context across concurrent tasks.
Rules
- UI work: persistence on @MainActor for the main context.
- Background: separate context / ModelActor pattern.
- Pass IDs / values across boundaries — re-fetch before mutating.
- Serialize conflicting updates through one owner when needed.
One-liner
“Own the context, don’t share it; pass IDs, then re-fetch and write in one place.”
IDs across isolation
// Pass PersistentIdentifier or stable IDs across tasks;
// re-fetch + mutate inside the destination ModelContext.
// await background.upsert(postID: id)