← All topics/Data persistence

Senior / Staff iOS — interview prep

Data persistence

storage · modeling · migrations · sync · scale · concurrency · SwiftData

KeychainCachesNormalizedMigrationsOutboxIdempotentModelContextMainActor
01

Choosing storage

Q1
Where 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

Q2
i
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

Q4
Layering
  • 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
SymptomTry
Slow list loadsIndexes, narrower fetches, pagination, avoid over-fetching relationships
Slow writesBatch inserts/updates, reduce save frequency in hot loops, move transforms off main
Memory spikesPage 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

Q7
Normalized

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)
Data persistence — interview prep cheat sheetAligned with practical question set