iOS Notes App System Design Interview Guide: Offline Sync, Rich Text & iCloud
The iOS notes app system design question is one of the most deceptive prompts in a senior iOS engineer's interview prep. A notes app sounds like the easiest thing you could be asked to design. You type, it saves. What's hard about that?
Everything. As soon as you add iCloud sync, multiple devices, attachments, rich text, shared notes, offline editing, and background refresh, the Notes app becomes a genuinely difficult engineering problem — wearing the disguise of a simple CRUD app. And that disguise is exactly what trips up candidates at Apple.
This gets asked at Apple (obviously), but also at Google, Notion, and any company building a first-party notes or document product. Candidates who sketch out a UITableView backed by Core Data and call it a day get filtered. Candidates who can reason through the sync layer, the conflict resolution strategy, the offline write model, and the memory constraints of large attachments — those are the candidates who move forward.
Candidates who explain what to build pass. Candidates who explain why each design decision was made get offers. That's what a strong iOS system design interview answer actually looks like — not a list of components, but a chain of reasoning. If you want a structured approach to framing these discussions, the complete mobile system design interview framework is highly applicable to iOS as well.
That gap — between knowing the answer and explaining it clearly under pressure — is exactly what Mockingly.ai is designed to close. But first, let's build the design.
Step 1: Clarify the Scope
Interviewer: Design the iOS Notes app.
Candidate: Before I jump in — a few questions to make sure I'm building the right thing. Are we designing the full Apple Notes product, or a stripped-down version? Specifically: are we scoping in iCloud sync across devices, or just local single-device storage? Should the app support rich text, or plain text only? Are we including attachments — photos, PDFs, sketches? What about shared/collaborative notes with real-time editing? And finally — is this a greenfield design, or are we designing an app that needs to interoperate with the existing Apple Notes format on macOS and web?
Interviewer: Good questions. Assume full iCloud sync across iPhone, iPad, and Mac. Rich text with basic formatting — bold, italic, bullet lists. Attachments: photos and sketches, not PDFs. No real-time collaboration for this round — collaborative notes are a bonus. We don't need macOS/web interop — iOS only.
Candidate: Perfect. That tells me a few important things. Without real-time collaboration, I can use a last-write-wins or vector clock approach for conflict resolution — we don't need CRDTs or operational transforms, which significantly simplifies the sync layer. Rich text with attachments means I need to think carefully about storage format and memory — I can't store
NSAttributedStringblobs naively in Core Data. And iCloud sync on iOS means I'll be reasoning aboutCloudKitspecifically, not a custom sync server (which would otherwise require us to architect a Content Delivery Network (CDN) for media). Let me walk through the design with those constraints in mind.
Requirements
Functional
- Create, read, update, and delete notes
- Rich text editing: bold, italic, underline, bullet lists, headings
- Embed photos and sketches (drawn via Apple Pencil or finger) within note body
- Organise notes into folders
- Full-text search across all notes and note titles
- Sync across iPhone, iPad, and Mac via iCloud
- Work fully offline — all operations available without connectivity
- Background sync when app is not in foreground
Non-Functional
- Offline-first — every read and write hits local storage first; sync is eventual, never blocking
- Low latency on open — the note list and last-opened note must load in under 200ms; no network round-trip on cold start
- Conflict resolution — when the same note is edited on two devices while offline, the app must detect and resolve the conflict without data loss
- Battery and memory efficiency — background sync must not drain battery; attachment loading must not spike memory
- Data durability — a note written on device must survive a crash, a background termination, and an iCloud outage
Device Storage & Sizing
Most candidates skip this entirely and jump to architecture. Don't. The numbers directly inform your storage format choice, how you structure Core Data entities, and your attachment loading strategy.
Interviewer: What kind of data volumes are we designing for?
Candidate:
Assume a typical heavy user on-device:
Notes count: ~2,000 notes
Avg note body size: ~5 KB plain text; rich text metadata ~2x = ~10 KB
Attachments: ~200 photos, ~1 MB each compressed on disk (stored separately)
Folders: ~20
Core Data SQLite footprint:
Note text data: 2,000 × 10 KB = ~20 MB
FTS search index: ~5 MB
Thumbnails (inline): 200 × 20 KB = ~4 MB
Total Core Data: ~29 MB ← well within SQLite's comfortable range on iOS
Attachment files on disk: 200 × 1 MB = ~200 MB ← stored outside Core Data
Peak memory for a single note:
10 embedded photos at full resolution → ~40–80 MB decoded UIImage memory
This is the real constraint. Memory, not storage.The dominant on-device constraint is memory when viewing attachment-heavy notes, not total storage. A note with 10 embedded photos requires aggressive lazy loading and thumbnail-first rendering — or you'll trigger memory pressure on smaller iPhones and get background-terminated.
High-Level Architecture
The iOS Notes app follows an offline-first, sync-later model. The local store is the source of truth. The cloud store is a replica that reconciles changes from multiple devices.
┌─────────────────────────────────────────────────────────────────────┐
│ iOS Notes App │
│ │
│ ┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐ │
│ │ UI Layer │ │ Domain / Use │ │ Sync Engine │ │
│ │ (SwiftUI / │◄──►│ Case Layer │◄──►│ (CloudKit │ │
│ │ UIKit) │ │ │ │ Manager) │ │
│ └──────────────┘ └────────┬─────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌──────────▼─────────────────────────▼─────┐ │
│ │ Local Store (Core Data / SwiftData)│ │
│ │ Notes | Attachments | Folders | Tombstones│ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ CloudKit Sync
▼
┌──────────────────┐
│ iCloud CloudKit │
│ (CKContainer) │
│ Private Database │
└──────────────────┘Request flow — creating a note:
- User types in
UITextViewbacked by the TextKit 2 stack (NSTextContentStorage+NSTextLayoutManager) - Debounced auto-save (500ms after last keystroke) writes to Core Data on a background context
- Core Data persists to SQLite — main thread never touches disk
NSPersistentCloudKitContainerpicks up the change viaNSPersistentStoreRemoteChangenotification- CloudKit syncs the delta to iCloud servers
- Other devices receive a
CKDatabaseSubscriptionpush notification, pull the delta, and merge into their local store
Request flow — opening a note on a second device:
CKDatabaseSubscriptionpush arrives — app background-refreshed viaBGAppRefreshTaskNSPersistentCloudKitContainerfetches changed records from CloudKit- Merge happens in Core Data — conflict resolution policy applied
- When user opens the note, it loads from Core Data — network never in the read path
Local Storage: Core Data, SwiftData, or SQLite?
This is the first real decision point an interviewer will probe.
The options:
- Core Data — Apple's ORM over SQLite, battle-tested, excellent CloudKit integration via
NSPersistentCloudKitContainer - SwiftData — Swift-native wrapper over Core Data, introduced iOS 17, cleaner API but a rocky production history
- SQLite (GRDB, FMDB) — raw control, custom sync, no native CloudKit integration
- Realm — third-party, excellent performance, own sync server (MongoDB Atlas Device Sync) — not iCloud
My recommendation for this design: Core Data with NSPersistentCloudKitContainer.
Here's why the alternatives lose:
SwiftData is appealing on paper, but its production stability has been inconsistent. The iOS 18 release introduced significant internal refactoring that broke code that ran correctly on iOS 17 — the community documented widespread regressions. SwiftData also lacks NSFetchedResultsController (which you need for memory-efficient list rendering), has no equivalent of groupBy fetch operations, and its CloudKit container configuration options are more limited than Core Data's. The API is the future, but it isn't mature enough for a complex sync-heavy app today.
SQLite directly gives you control but you lose the entire NSPersistentCloudKitContainer integration. You'd have to write your own CloudKit sync layer — record reconciliation, change tokens, conflict detection — that's thousands of lines of code Apple already ships. Never build what the platform gives you unless you have a concrete reason.
Core Data is the right choice here. Not because it's simple — it isn't — but because its integration with CloudKit is the most battle-tested solution on iOS for private data sync.
Schema design:
// Core Data entity: Note
entity Note {
id: UUID // stable ID across devices
title: String
bodyData: BinaryData // serialised NSAttributedString (RTF or custom format)
createdAt: Date
modifiedAt: Date
serverModifiedAt: Date // CloudKit server timestamp, used for conflict resolution
isDeleted: Bool // soft delete — tombstone for sync
folder: Folder // relationship
attachments: [Attachment] // relationship
}
// Core Data entity: Attachment
entity Attachment {
id: UUID
type: String // "photo" | "sketch"
assetURL: String // local file URL in app's document directory
thumbnailData: BinaryData // 200×200px JPEG, inline in Core Data (small)
fileSize: Int64
note: Note // inverse relationship
}
// Core Data entity: Folder
entity Folder {
id: UUID
name: String
sortIndex: Int32
notes: [Note]
}Notice the use of UUID for identifiers. Since this is an offline-first app where entities are created on-device before syncing, generating IDs locally via UUID avoids network round-trips. If this were a purely cloud-based system at massive scale, you might instead discuss a distributed ID generator for the backend.
The key design decision here: attachment binary data does not live in Core Data. Only the thumbnail does. Full-resolution images are stored in the app's ~/Documents directory and referenced by assetURL. Why? Core Data is backed by SQLite — storing large BLOBs inside SQLite rows balloons the WAL file, slows vacuum operations, and prevents CloudKit from syncing attachments as separate CKAsset objects. Keeping assets out of Core Data rows is non-negotiable.
Rich Text Editing: NSAttributedString, TextKit 2, and Storage Format
This is an iOS-specific depth area most system design articles completely skip.
You can't just throw NSAttributedString into a UITextView and call it done for an app that syncs across platforms and survives version upgrades.
The editing stack:
UITextViewfor input — uses TextKit 2 by default from iOS 16 (APIs available from iOS 15)NSTextContentStorage+NSTextLayoutManager— the TextKit 2 stack that replaced the legacy TextKit 1 classesNSTextContentStorageis the concrete implementation ofNSTextContentManagerand owns theNSAttributedString; changes are observed viaNSTextStorageDelegate- Attachment rendering:
NSTextAttachmentwithNSTextAttachmentViewProviderfor embedded image/sketch views
One real-world caveat worth naming in an interview: as of iOS 18, TextKit 2 still has known issues with scrolling stability and height estimation in documents with many large attachments. For a notes app with mostly text and occasional images, it's fine. For a document editor with dense media, you may want to test carefully and potentially fall back to TextKit 1 for specific content types.
The storage format problem:
NSAttributedString is not a stable format. Archiving it with NSKeyedArchiver creates opaque blobs tied to the iOS SDK version. If Apple changes internal attribute keys between SDK versions — which has happened — your archived notes can become unreadable without explicit migration code.
Three options:
- RTF / RTFD —
NSAttributedString(rtf:documentAttributes:). Cross-platform, stable, but lossy for custom attributes (Apple-specific formatting, inline sketches) - NSKeyedArchiver with custom version key — fast, but fragile across SDK versions; requires explicit migration on upgrade
- Custom JSON serialisation — you define the format, you own the stability. More work upfront, zero vendor lock-in
For this interview context, I'd go with custom JSON serialisation. It lets you be explicit about what you support (bold, italic, underline, ordered/unordered lists, headings, image attachments) rather than preserving every NSAttributedString key that might be set by the system. When you're asked "how do you handle format migrations?", JSON with explicit versioning is an easy answer.
{
"version": 2,
"blocks": [
{ "type": "heading1", "text": "Meeting Notes" },
{ "type": "paragraph", "spans": [
{ "text": "Action item: ", "bold": true },
{ "text": "ship the sync layer", "bold": false }
]},
{ "type": "attachment", "attachmentId": "uuid-xyz", "kind": "photo" },
{ "type": "bulletList", "items": ["Write tests", "Ship it"] }
]
}This is a simplified block-based model — very similar to what Notion uses internally. It serialises cleanly to JSON, diffs predictably, and survives format version upgrades through a migration registry keyed by version.
The interviewer will likely ask: "what did you trade away going custom vs. RTF?" Trade-offs: you lose macOS interop and clipboard compatibility. You gain explicit control and stable migrations.
Sync Architecture: iCloud CloudKit
NSPersistentCloudKitContainer handles most of this — but knowing what it does under the hood is what separates a passable answer from a senior answer.
How CloudKit private database sync works:
- Each device maintains a change token — a server-side cursor. Fetching changes after a token returns only the delta since last sync.
- Records in CloudKit are identified by
CKRecordID— mapped to your Core Data object's UUID. NSPersistentCloudKitContainerwraps each entity's changes intoCKRecordobjects and callsCKModifyRecordsOperationto push.- Subscriptions (
CKDatabaseSubscription) deliver push notifications when the server has new changes — the app fetches the delta inBGAppRefreshTask.
Tombstones — the sync edge case most candidates miss:
When you delete a note locally, you can't just call context.delete(note). If you delete the Core Data object before it syncs, CloudKit never learns about the deletion — and the note comes back the next time the other device pushes its version.
The fix: soft delete. Set isDeleted = true, let CloudKit sync that flag, then hard-delete on the other device after it receives the tombstone. The deletion propagates. After a grace period (e.g., 30 days), a cleanup BGProcessingTask hard-deletes tombstoned records from both Core Data and CloudKit.
func deleteNote(_ note: Note, in context: NSManagedObjectContext) {
note.isDeleted = true
note.modifiedAt = Date()
// Don't call context.delete(note) yet.
// NSPersistentCloudKitContainer will sync the tombstone.
try? context.save()
}Conflict Resolution
Two devices edit the same note while offline. They both come back online. What happens?
With NSPersistentCloudKitContainer's default merge policy, the last write wins based on modifiedAt timestamp. That's fine for most notes. But it's not enough if you care about preserving both edits.
Three strategies:
- Last-Write-Wins (LWW) — compare
serverModifiedAttimestamps; keep the latest. Simple, lossy. Apple's default. - Merge on field level — if device A changed the title and device B changed the body, you can merge both. Requires tracking dirty fields per save.
- Conflict UI — surface a "Note conflict" UI showing both versions and let the user pick. Notes.app actually does this for edge cases.
For the interview, I'd recommend: LWW as default + conflict UI for the edge case where both devices modified the body within the same session window. Implement LWW via a custom NSMergePolicy subclass that compares timestamps rather than object graph state.
class TimestampMergePolicy: NSMergePolicy {
override func resolve(optimisticLockingConflicts list: [NSMergeConflict]) throws {
for conflict in list {
let objectTimestamp = conflict.objectSnapshot?["modifiedAt"] as? Date
let contextTimestamp = conflict.cachedSnapshot?["modifiedAt"] as? Date
if let obj = objectTimestamp, let ctx = contextTimestamp, ctx > obj {
// Context version is newer — keep context, discard persistent store version
conflict.sourceObject.willAccessValue(forKey: nil)
} else {
// Persistent store version is newer — standard re-fetch
try super.resolve(optimisticLockingConflicts: [conflict])
}
}
}
}The interviewer's follow-up here is usually: "what if you need real-time collaboration?" That's where OT/CRDTs come in — see the Bonus section.
For a deeper look at the underlying concepts, see this collaborative editor system design guide which covers OT and CRDTs in detail.
Search: Core Spotlight and SQLite FTS5
Notes search needs to be fast, offline, and indexable by iOS's system-wide Spotlight search.
Two layers:
Layer 1: SQLite FTS5 via a separate SQLite store
Core Data does not support FTS natively — despite being backed by SQLite internally, it doesn't expose FTS5 virtual tables. The correct approach is to maintain a separate SQLite database (using GRDB, FMDB, or raw libsqlite3) alongside Core Data's store, dedicated to the FTS index.
Important: do not try to use content= to point the FTS5 table back at Core Data's internal SQLite tables. Core Data uses internally generated table names with a Z prefix (e.g., ZNOTE, not notes) that are unstable implementation details — referencing them directly breaks across Core Data migrations.
Use a content-less FTS5 table instead. It maintains its own copy of the indexed text:
-- In a separate notes_search.sqlite file
CREATE VIRTUAL TABLE notes_fts USING fts5(
note_id UNINDEXED,
title,
body_text
-- no content= clause: content-less table owns its text data
);When a note is saved, extract the plain text from your rich text format and write to the FTS table:
func updateSearchIndex(noteId: String, title: String, bodyText: String) {
// Run on background queue — never on main thread
try db.run("""
INSERT OR REPLACE INTO notes_fts(note_id, title, body_text)
VALUES (?, ?, ?)
""", [noteId, title, bodyText])
}
func search(query: String) -> [String] {
// Returns note_id strings, then fetch Note objects from Core Data
return try db.prepare("""
SELECT note_id FROM notes_fts WHERE notes_fts MATCH ?
ORDER BY rank
""", [query]).map { $0[0] as! String }
}This gives you sub-10ms search on 10,000 notes on-device with no network round-trip, plus BM25 relevance ranking for free.
Layer 2: Core Spotlight
CSSearchableItem lets your notes appear in iOS Spotlight and Siri Suggestions. Index each note on create/update:
func indexNote(_ note: Note) {
let attributeSet = CSSearchableItemAttributeSet(contentType: .text)
attributeSet.title = note.title
attributeSet.contentDescription = String(note.bodyText.prefix(200))
attributeSet.lastUsedDate = note.modifiedAt
let item = CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "ai.mockingly.notes",
attributeSet: attributeSet
)
CSSearchableIndex.default().indexSearchableItems([item]) { error in
// handle
}
}The Spotlight index is maintained separately from the FTS table — don't conflate them. Spotlight serves system-level discoverability; FTS serves in-app search.
Memory Management and Attachment Loading
This is the part that differentiates senior iOS engineers from mid-level engineers in an interview.
A note with 10 photos can theoretically require 40–80 MB of RAM if every photo is decoded at full resolution into a UIImage. On an iPhone with tight memory, that triggers didReceiveMemoryWarning and can cascade into background terminations. Handling image memory efficiently is a core challenge across mobile system design—you'll see similar constraints when designing a photo sharing app.
The rules:
- Never load full-resolution images into
UITextViewinline content. Always display thumbnails in the note body. Load full resolution only when the user taps to expand. - Thumbnails are pre-generated and stored in Core Data (as the
thumbnailDatafield above) — small enough (< 20 KB) to be inline, avoids disk I/O on scroll. - Full-resolution images live on disk, loaded lazily via
UIImage(contentsOfFile:)on demand, not preloaded. - Purge image caches on
UIApplicationDelegate.applicationDidReceiveMemoryWarning— useNSCache(not aDictionary) for decoded images; it evicts automatically under memory pressure. UITextViewwith largeNSAttributedStrings: preferNSTextContentStorage+NSTextLayoutManager(TextKit 2) over TextKit 1 — it uses lazy layout and avoids laying out the full document on first render.
// Correct: NSCache for decoded full-res images — auto-evicts under pressure
private let imageCache = NSCache<NSString, UIImage>()
func image(for attachment: Attachment) -> UIImage? {
let key = attachment.id.uuidString as NSString
if let cached = imageCache.object(forKey: key) { return cached }
guard let url = attachment.localURL,
let image = UIImage(contentsOfFile: url.path) else { return nil }
imageCache.setObject(image, forKey: key, cost: Int(attachment.fileSize))
return image
}Background Sync: BGTaskScheduler
iCloud sync needs to happen when the app is backgrounded. iOS gives you two task types:
BGAppRefreshTask— short, triggered by the system, ~30 seconds. Good for pulling CloudKit delta.BGProcessingTask— longer, triggered at night on power, minutes. Good for hard-deleting tombstones, rebuilding FTS index, compacting Core Data WAL.
Registration:
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "ai.mockingly.notes.sync",
using: nil
) { task in
self.handleSyncTask(task as! BGAppRefreshTask)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "ai.mockingly.notes.maintenance",
using: nil
) { task in
self.handleMaintenanceTask(task as! BGProcessingTask)
}In the sync handler:
func handleSyncTask(_ task: BGAppRefreshTask) {
task.expirationHandler = {
// Cancel in-flight operations
self.syncOperation?.cancel()
}
// NSPersistentCloudKitContainer will handle the actual sync
// We just need to trigger a context save to flush pending changes
let context = persistentContainer.newBackgroundContext()
context.perform {
try? context.save()
task.setTaskCompleted(success: true)
self.scheduleNextSyncTask() // reschedule
}
}One thing interviewers will catch you on: BGAppRefreshTask is not guaranteed to fire at the time you request. The system decides. Don't design for precise timing — design for eventual consistency. Notes sync is naturally eventual; lean into it.
Pagination and Note List Performance
The note list can have thousands of notes. Loading all of them into memory at once for a UITableView or List in SwiftUI is wrong.
Use NSFetchedResultsController (for UIKit) or @FetchRequest with fetchLimit (for SwiftUI). For the list, you only need title, modifiedAt, and a snippet of the body — not the full body data.
let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "isDeleted == NO AND folder == %@", selectedFolder)
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Note.modifiedAt, ascending: false)]
fetchRequest.fetchBatchSize = 20 // SQLite fetches 20 rows at a time
fetchRequest.propertiesToFetch = ["id", "title", "modifiedAt", "bodySnippet"]
fetchRequest.includesPropertyValues = true
fetchRequest.returnsObjectsAsFaults = false // prefetch listed properties onlyfetchBatchSize = 20 is key. Core Data will lazily fault in additional batches as you scroll, keeping memory flat regardless of total note count.
For the note body snippet displayed in the list, maintain a separate bodySnippet field (first 150 characters of plain text) — don't parse the full bodyData blob for every list cell.
Bonus: Real-Time Collaboration (Advanced)
The interviewer asked to scope this out, but will almost certainly circle back with: "If you had to add collaborative editing, how would you approach it?"
The short answer: you'd need CRDTs (Conflict-free Replicated Data Types), specifically a CRDT sequence type for text (like RGA or LSEQ), because operational transforms (OT) require a central server to order operations — and iCloud's P2P sync model doesn't have one.
The architecture shift:
- Replace
bodyData(serialised rich text) with a CRDT document (e.g., using a library like Yjs via Swift bridging, or implementing a native Swift CRDT) - Each character insertion/deletion becomes a CRDT operation with a unique vector clock
- Operations are merged without conflict — no "last write wins"
- CloudKit becomes the transport layer for CRDT operation logs, not full document state
This significantly increases storage size (operation logs grow unboundedly unless compacted) and adds implementation complexity. For this reason, Apple's current Notes collaboration uses a custom sync protocol, not generic CRDTs — though the architecture is philosophically similar.
See this real-time messaging system design guide for a parallel look at how real-time delivery works at the transport layer, and the collaborative editor system design guide for a deep dive into OT vs CRDTs.
iOS System Design Interview Follow-ups
Q: What happens when iCloud is disabled on the device? Does the app still work?
Yes — that's a requirement of offline-first. The Core Data stack works independently of the CloudKit container. When iCloud is unavailable, NSPersistentCloudKitContainer simply doesn't sync. All reads and writes continue against the local SQLite store. When iCloud becomes available again, the container resumes syncing from the last change token. The app should surface a quiet status indicator ("Sync paused") but never block the user.
Q: A user has 5,000 notes and opens the app for the first time on a new iPhone. How do you handle the initial sync?
This is the "cold start" problem. CloudKit will push all records to the new device. With 5,000 notes × 10 KB average = 50 MB of text data, plus potentially hundreds of MB of attachments, the initial sync can take minutes. The strategy: prioritise syncing the 20 most recently modified notes first (these are what the user will touch), and load the rest in the background. NSPersistentCloudKitContainer doesn't natively support priority ordering — you'd implement a custom CKFetchRecordsOperation sorted by modificationDate descending to do the first page, then batch the rest.
Q: How do you prevent battery drain from aggressive background sync?
BGTaskScheduler already throttles BGAppRefreshTask scheduling — the OS batches background activity with other apps to minimise wakeups. On your side: don't reschedule immediately after completion; use an exponential backoff strategy (5 min → 15 min → 30 min) that resets to 5 min on the next foreground event. Avoid creating background URLSession tasks for every single note change — batch deltas into a single CKModifyRecordsOperation per sync cycle.
Q: How would you test the sync layer?
The sync layer is notoriously hard to unit test because it depends on iCloud accounts. The strategy: wrap NSPersistentCloudKitContainer behind a protocol (SyncEngineProtocol) with methods like push(changes:) and fetchChanges(completion:). In tests, inject a MockSyncEngine that captures pushed records. For integration testing, Apple provides NSPersistentCloudKitContainer.initializeCloudKitSchema() which validates the schema against a simulated CloudKit container without hitting live servers. End-to-end sync tests require two actual simulator instances with the same test iCloud account — automate with XCUITest.
Q: The user deletes a folder that has 200 notes inside. How do you handle this?
Cascade delete — but carefully. In Core Data, set the delete rule on the Folder → notes relationship to .cascade. This deletes all Note objects when the folder is deleted. But because we use soft deletion for sync, the cascade must set isDeleted = true on all child notes rather than hard-deleting them. Implement this via NSManagedObject's prepareForDeletion() override in Folder: iterate self.notes, set each note.isDeleted = true. CloudKit will sync all 200 tombstones. After the grace period, BGProcessingTask hard-deletes them.
Q: How do you handle the case where the user changes their iCloud account?
NSPersistentCloudKitContainer does not automatically wipe local data when the iCloud account changes. You need to observe CKAccountStatus changes via CKContainer.default().accountStatus and the .CKAccountChanged notification. When the account changes, the correct response depends on product decision: either show a data ownership prompt ("This device has local data from [previous account]. Sign in with that account to sync it, or clear and start fresh") or automatically wipe and re-sync from the new account. Apple's own Notes.app shows an alert. Never silently cross-contaminate notes between accounts.
Quick Interview Checklist
Before you wrap up, make sure you've covered:
- Offline-first: all reads and writes hit Core Data first, never blocked by network
-
NSPersistentCloudKitContaineras the sync layer — not a custom CloudKit implementation - Soft delete / tombstones for safe CloudKit deletion propagation
- Attachment storage decoupled from Core Data rows (stored in
~/Documents, referenced by URL) - Thumbnails inline in Core Data; full-res loaded lazily on demand
- Custom rich text serialisation format (JSON blocks) for stable cross-version migrations
- Separate SQLite FTS5 store for in-app search (not Core Data's internal tables);
CSSearchableItemfor Spotlight integration - Conflict resolution policy — LWW by default, surfaced UI for body conflicts
-
BGAppRefreshTaskfor lightweight sync;BGProcessingTaskfor maintenance -
fetchBatchSizeonNSFetchRequestfor note list memory efficiency -
NSCache(notDictionary) for decoded image caching — auto-evicts under memory pressure - iCloud account change handling — don't silently cross-contaminate notes
Conclusion
The iOS Notes app is one of those questions that rewards preparation disproportionately. Everyone can sketch a UITableView over Core Data. But the candidates Apple hires are the ones who can articulate why soft delete matters for sync, why attachments shouldn't live in Core Data rows, and why LWW conflict resolution is the right default while still knowing when you'd need something stronger.
The depth isn't in any single component — it's in how the components connect. The offline-first constraint shapes the sync layer. The sync layer shapes the delete model. The delete model shapes the schema. That chain of reasoning is what every iOS system design interview is ultimately testing.
If you want to practice walking through this under real interview pressure, Mockingly.ai runs mock system design interviews specifically for iOS engineers. The gap between knowing this design and explaining it confidently in 45 minutes is real — and it's closeable.
For more iOS and Android system design practice, the Android Notes App System Design guide walks through the same problem on Android — useful for understanding where the platforms diverge (Room vs Core Data, WorkManager vs BGTaskScheduler, Firebase vs CloudKit).
Frequently Asked Questions
What is iOS Notes App system design?
iOS Notes App system design is the interview problem where candidates are asked to architect a production-quality note-taking application for iOS, covering local storage, iCloud sync, rich text editing, attachment handling, search, and offline-first behaviour. It's a common question at Apple, Google, and companies building document or productivity apps, and it tests both iOS-specific knowledge (Core Data, CloudKit, TextKit) and general distributed systems thinking (conflict resolution, sync protocols, eventual consistency).
What storage solution should I use for an iOS notes app in a system design interview?
Core Data with NSPersistentCloudKitContainer is the right answer for an iOS notes app that requires iCloud sync. It provides native CloudKit integration, change tracking, NSFetchedResultsController for memory-efficient list rendering, and mature merge policy support. SwiftData (iOS 17+) has a cleaner API but experienced significant stability regressions in iOS 18 and still lacks equivalents for NSFetchedResultsController and groupBy fetch operations. Custom SQLite (via GRDB or FMDB) gives more control but requires building the entire CloudKit sync layer from scratch.
How does iCloud sync work for an iOS notes app?
iCloud sync for an iOS notes app is handled by NSPersistentCloudKitContainer, which maps Core Data entities to CloudKit records in the user's private iCloud database. Changes on one device are pushed to CloudKit via CKModifyRecordsOperation. Other devices receive push notifications via CKDatabaseSubscription and pull changes using a server-side change token. Merge conflicts are resolved via a configurable NSMergePolicy. The sync is fully eventual — the local store is always the source of truth, and sync never blocks reads or writes.
How do you handle sync conflicts in an iOS notes app?
The default strategy is last-write-wins (LWW): compare modifiedAt timestamps across conflicting versions and keep the most recent. Implement this via a custom NSMergePolicy subclass that checks timestamps before deferring to Core Data's default resolution. For the edge case where both devices edited the note body within the same offline window, surface a conflict UI showing both versions. Avoid CRDTs unless you need real-time multi-user collaboration — they add significant complexity for single-user multi-device sync.
Why shouldn't attachments be stored directly in Core Data?
Storing large binary data (photos, sketches) directly in Core Data rows is a mistake because Core Data is backed by SQLite. Large BLOBs in SQLite rows cause WAL file bloat, slow vacuum and checkpoint operations, and prevent CloudKit from treating attachments as separate CKAsset objects with their own upload/download lifecycle. The correct pattern is to store full-resolution attachments in the app's ~/Documents directory, reference them by file URL in Core Data, and store only a small thumbnail (< 20 KB) inline as a derived property.
Which companies ask iOS notes app system design in interviews?
Apple asks this question directly — it's one of their most common iOS system design prompts at the senior engineer and staff level. Google asks notes-app-style questions to evaluate mobile candidates' understanding of offline-first architecture and sync. Notion and similar document-product companies ask variants of this to evaluate understanding of rich text models and conflict resolution. The core concepts — offline-first storage, sync, conflict resolution — also appear at Dropbox, Microsoft (OneNote), and any company building a first-party mobile productivity app.
How does the iOS notes app system design interview differ at Apple vs Google vs Notion?
At Apple, the emphasis is on privacy-first design and on-device behaviour — interviewers want to see you treat the device as the source of truth and push data to the cloud only with explicit justification. Expect deep follow-ups on iCloud sync, conflict resolution, and data isolation between accounts. At Google, the question is more likely framed around Android, but if asked in an iOS context, expect focus on scalability of the sync layer and observability. At Notion, the emphasis shifts toward rich text model design and conflict resolution for collaborative editing — the block-based document format and how it diffs and merges is the core of what they care about.
How long should I spend on the iOS Notes App design in an interview?
In a 45-minute system design interview, allocate roughly: 5 minutes on scope clarification, 3 minutes on requirements, 5 minutes on estimates, 12 minutes on high-level architecture and storage decision, 15 minutes on the sync layer and conflict resolution (this is where most depth gets evaluated), and 5 minutes on mobile-specific concerns (memory, background sync, search). The sync architecture is the hardest part — if you're running short on time, cut depth on the rich text section before cutting depth on sync.
What is the difference between BGAppRefreshTask and BGProcessingTask for notes sync?
BGAppRefreshTask is a short-lived background task (approximately 30 seconds) that the system schedules opportunistically based on the app's usage patterns. Use it for lightweight operations like pulling a CloudKit delta or flushing a pending Core Data save. BGProcessingTask is a longer-running task (minutes) that the system typically runs when the device is on power and connected to Wi-Fi, usually overnight. Use it for heavyweight maintenance: hard-deleting tombstoned records, rebuilding the FTS index in the separate SQLite store, or compacting the Core Data WAL file. Neither task is guaranteed to run at the exact time you request — design your sync model to be resilient to delayed or skipped background execution, because the OS controls the schedule.