Design a Notes App (Android)

medium20 minAndroid System Design
Key Topics
notes appoffline firstroomworkmanagerkotlinconflict resolutionfts4

Android System Design: Design a Notes App

Designing a notes app might be the most underestimated question in Android system design interviews. Candidates hear "notes app" and think CRUD. Then the interviewer asks how you handle a note edited on two different devices while both were offline, and suddenly you're deep in conflict resolution strategies, vector clocks, and the hard tradeoffs between user experience and data safety.

This question shows up at Google, Microsoft, Notion, Dropbox, and pretty much any company building productivity tools on Android. It's popular precisely because it's a clean, bounded problem that happens to touch every hard Android topic at once: offline-first design, Room schema decisions, background sync with WorkManager, multi-device state reconciliation, and rich text storage.

The best candidates don't just describe a notes CRUD app — they treat it as a distributed systems problem running on a constrained mobile device.


Step 1: Clarify the Scope

Interviewer: Design a notes app for Android.

Candidate: Before I dive in — a few questions. Are we designing a local-only notes app or one that syncs across devices? If it syncs, do we need real-time collaboration, or is eventual consistency between the user's own devices acceptable? Do notes support rich text — bold, italics, lists, embedded images — or plain text only? Do we need folder organisation, tagging, or search? And what's the offline expectation — can the user create, edit, and read notes without network access?

Interviewer: The app syncs across the user's own devices. No real-time collaboration — just eventual consistency. Rich text support — at least bold, italic, bullet lists. Tags and search are in scope. Full offline support is a hard requirement. Assume Google Keep or Apple Notes as the reference.

Candidate: Perfect. Offline-first is the most important architectural constraint here — it shapes every single decision from schema design to sync strategy. Let me start with requirements and then walk through the architecture.


Requirements

Functional

  • Create, edit, and delete notes fully offline
  • Notes contain rich text (bold, italic, bullet lists) and optionally images/attachments
  • Tag notes for organisation; filter notes by tag
  • Full-text search across note content and title
  • Notes sync across the user's own devices when connectivity is available
  • Handle conflicts when the same note is edited on two devices while offline
  • Archive and pin notes

Non-Functional

  • Offline-first — all core operations must work without a network connection
  • Zero perceived latency — UI writes must feel instant; syncing happens in the background
  • No data loss — a note written offline must never be silently dropped
  • Battery-safe — background sync must not drain the battery
  • Conflict resilience — concurrent edits across devices must be resolved correctly and predictably

Back-of-the-Envelope Estimates

Interviewer: Give me a sense of the numbers.

Candidate: For a per-device estimate:

plaintext
Notes per user:             ~500 average (active user)
Average note size (text):   ~5 KB
Notes with attachments:     ~10%
Average attachment size:    ~500 KB (photos, audio clips)
 
Local storage (text only):
  500 notes × 5 KB = ~2.5 MB — trivially small, no concern
 
Local storage (with attachments):
  50 attachments × 500 KB = ~25 MB — manageable per device
 
Sync payload per session:
  Assume user edits 5 notes per day
  5 × 5 KB = 25 KB delta upload — negligible bandwidth
 
Search index:
  Room FTS4 overhead is ~2× the text content size
  500 notes × 5 KB × 2 = ~5 MB — fine for SQLite

The numbers confirm this is not a scale problem on the client. Notes apps don't have high write throughput or large data volumes per device. The interesting challenge is correctness — not performance. Specifically: how do you guarantee offline writes don't get lost, and what happens when two devices edit the same note while offline.


Client Architecture

Interviewer: Walk me through the overall architecture.

Candidate: Clean Architecture with MVVM, Room as the single source of truth, and WorkManager handling all background sync.

plaintext
UI Layer
  NoteListScreen / NoteEditScreen  ←  ViewModel (StateFlow / Compose State)
 
Domain Layer
  Use Cases: CreateNote, UpdateNote, DeleteNote, SearchNotes, SyncNotes
 
Data Layer
  NoteRepository
    ├── Local Source  → Room (all reads and writes, always)
    └── Remote Source → Retrofit (sync only, never on the critical path)
 
Sync Layer
  NoteSyncWorker (WorkManager CoroutineWorker)
  SyncStatusTracker (exposes sync state to UI)

The critical principle: the UI never talks to the network directly. Every read and write goes through Room. The ViewModel observes Room via Flow — when a note is saved, Room emits the update and the UI reacts. Sync happens entirely in the background.

This is what makes offline feel seamless. When a user types a note with no internet, the experience is identical to when they have full connectivity. Room doesn't know or care about the network.


Room Schema Design

This is the first place interviewers probe in depth. A weak schema here cascades into problems with sync, conflict resolution, and search.

Interviewer: Walk me through the Room schema for a notes app.

Candidate: Let me walk through each entity and explain why each field exists.

kotlin
@Entity(tableName = "notes")
data class NoteEntity(
    @PrimaryKey val id: String,          // UUID — client-generated, not server auto-increment
    val title: String,
    val contentJson: String,             // rich text serialised as JSON (covered below)
    val contentPlainText: String,        // stripped plain text — for FTS indexing
    val isPinned: Boolean = false,
    val isArchived: Boolean = false,
    val color: String? = null,           // hex colour string, nullable
    val createdAt: Long,                 // epoch millis, set on creation, never changes
    val updatedAt: Long,                 // epoch millis, updated on every local edit
    val serverUpdatedAt: Long? = null,   // the server's last-modified timestamp — for conflict detection
    val syncStatus: SyncStatus,          // SYNCED, PENDING, CONFLICTED
    val isDeleted: Boolean = false       // soft delete — never hard-delete before sync
)
 
enum class SyncStatus { SYNCED, PENDING, CONFLICTED }

A few decisions to call out explicitly.

Client-generated UUID. If the primary key were a server-assigned integer, the client couldn't create a note offline — it would have no ID until the server responds. A UUID generated on the device means the note has an ID from the moment of creation, before any network interaction.

contentPlainText alongside contentJson. Rich text content is stored as JSON (explained below). But Room FTS can't tokenise JSON structure — it needs plain text. So we maintain a stripped version for search indexing.

syncStatus. This is the heartbeat of the offline-first design. Every note is in one of three states: SYNCED (local and server agree), PENDING (local change not yet sent to server), or CONFLICTED (a conflict was detected that needs resolution). The sync worker queries for PENDING notes; the UI surfaces CONFLICTED notes to the user.

Soft delete with isDeleted. Never hard-delete a note before it's synced. If you delete a note locally and it's never been synced, you need to inform the server on the next sync. A hard delete loses that signal.

Tags schema:

kotlin
@Entity(tableName = "tags")
data class TagEntity(
    @PrimaryKey val id: String,
    val name: String,
    val userId: String
)
 
@Entity(
    tableName = "note_tags",
    primaryKeys = ["noteId", "tagId"]
)
data class NoteTagEntity(
    val noteId: String,
    val tagId: String
)

FTS virtual table for full-text search:

kotlin
@Fts4(contentEntity = NoteEntity::class)
@Entity(tableName = "notes_fts")
data class NotesFts(
    val title: String,
    val contentPlainText: String
)

Room's @Fts4 annotation creates a SQLite full-text search virtual table backed by contentPlainText and title. FTS4 tokenises content at write time, enabling MATCH queries that are orders of magnitude faster than LIKE '%term%' on large note collections. We'll use this for the search feature.


Rich Text Storage

Interviewer: How do you store and render rich text in the note editor?

Candidate: This is an area where candidates often say "use a WebView" and move on. Let me give a more considered answer.

What it is: Android's native text system uses Spanned / SpannableString — in-memory objects that hold text alongside formatting ranges (bold from position 5 to 10, italic from 20 to 25, etc.). These are great for rendering in EditText but can't be directly persisted to Room.

The storage strategy: serialise the rich text document to JSON. The format stores the raw text plus an array of spans with their type, start, and end positions:

json
{
  "text": "Meeting notes\nDon't forget to review the PRD",
  "spans": [
    { "type": "BOLD", "start": 0, "end": 13 },
    { "type": "ITALIC", "start": 29, "end": 43 }
  ]
}

On write: convert SpannableString → JSON, store in contentJson. Also extract text as contentPlainText for FTS.

On read: deserialise JSON → rebuild SpannableString and display in EditText.

Markdown as an alternative: some notes apps (Notion, Bear) store content as Markdown strings. This is simpler to serialise and portable, but rendering Markdown in Android requires either a third-party library (Markwon is the standard) or a WebView. The trade-off: Markdown is easier to sync and diff across devices (it's just text), but native Markdown rendering is heavier than SpannableString. For a Google Keep-style app with basic formatting, JSON-serialised spans is the right call. For a Notion-style app with complex block structures, consider a block-based document model.

What not to do: never store HTML in the database. HTML is verbose, hard to diff, and the WebView it requires for rendering is expensive for list scrolling.


Offline-First Architecture: The Write Path

Interviewer: Walk me through what happens when the user creates a note with no internet connection.

Candidate: This is the critical path, and it's entirely local:

plaintext
User types in NoteEditScreen


ViewModel.saveNote()
  │  Called on a debounced timer (500ms after typing stops)

NoteRepository.save(note)


Room.insert/update(note.copy(syncStatus = PENDING, updatedAt = now()))
  │  Returns immediately — sub-millisecond

UI observes Room Flow → updates note list with "saving..." indicator


WorkManager enqueued with CONNECTED constraint
  (No-op if already enqueued for this note — idempotent enqueue)

The user sees their note saved instantly. There's no loading spinner, no "waiting for network" state. The PENDING status is invisible to the user at this point — it's an internal state that drives the sync pipeline.

The debounce is important. Notes apps don't save on every keystroke — that would flood Room with writes. A 500ms debounce means one Room write per burst of typing, not one per character. On process kill, the ViewModel's onCleared() triggers an immediate save of any pending debounced content so nothing is lost.


Background Sync: WorkManager

What WorkManager does: WorkManager is the Jetpack-recommended solution for background work that must complete, even if the app is killed or the device restarts. It persists work to a Room-backed queue, respects battery constraints, and retries with exponential backoff on failure.

Interviewer: How does sync work in the background?

Candidate: The NoteSyncWorker runs in three scenarios: when the user explicitly triggers a sync, when the network becomes available (via ConnectivityManager.NetworkCallback), and on a periodic schedule (every hour).

The worker runs as a CoroutineWorker — safe to do Room reads and API calls without blocking the main thread:

plaintext
NoteSyncWorker.doWork():
  1. Query Room for all notes where syncStatus = PENDING
  2. For each PENDING note:
     a. Try POST/PUT to sync API with { id, contentJson, updatedAt }
     b. Server responds with { serverId, serverUpdatedAt, conflicted: bool }
     c. If no conflict:
           Room update: syncStatus = SYNCED, serverUpdatedAt = response.serverUpdatedAt
     d. If conflict:
           Room update: syncStatus = CONFLICTED
           Store server version alongside local version
  3. Pull new notes from server (delta pull — send lastSyncTimestamp)
     For each server note:
        If note not in Room: INSERT (syncStatus = SYNCED)
        If note in Room and serverUpdatedAt > local serverUpdatedAt:
            Check for PENDING local changes → potential conflict
  4. Return Result.success() or Result.retry() on transient failure

Enqueue the worker as unique work using ExistingWorkPolicy.KEEP. This prevents multiple simultaneous sync workers from racing each other:

plaintext
WorkManager.enqueueUniqueWork(
    "notes_sync",
    ExistingWorkPolicy.KEEP,
    syncRequest
)

For the periodic sync, use enqueueUniquePeriodicWork with ExistingPeriodicWorkPolicy.KEEP to prevent redundant periodic sync registrations across app restarts.


Conflict Resolution: The Hard Part

This is where the interview gets interesting, and where the most valuable answers live. A notes app syncing across devices will inevitably have conflicts — the same note edited on two devices while both were offline.

Interviewer: The user edits a note on their phone while offline. The same note was edited on their tablet while that was also offline. Both devices reconnect. What happens?

Candidate: This is the classic offline sync conflict, and there are three resolution strategies — each with different trade-offs.

Strategy 1: Last-Write-Wins (LWW)

Compare updatedAt timestamps. The version with the more recent timestamp wins; the other is discarded.

plaintext
Phone version:  updatedAt = 14:00, content = "Meeting agenda: review PRD"
Tablet version: updatedAt = 14:05, content = "Meeting agenda: cancelled"
Winner: Tablet (newer timestamp)
Phone's changes silently discarded.

Simple to implement, zero user friction — but can silently discard work the user cares about. Acceptable for apps where notes are short and the cost of a lost edit is low. Not acceptable if a user writes a 500-word note on their phone and later fixes a typo on their tablet.

Strategy 2: Show Both — User Resolves

When a conflict is detected, mark the note as CONFLICTED in Room. Store both versions: the local version and the incoming server version. Surface the conflict in the UI — a "conflict detected" badge on the note list item. When the user taps it, show a diff view with both versions and let them choose which to keep, or manually merge.

This is the safest option for user data — nothing is lost. But it requires a UI for conflict resolution (a diff view is non-trivial to build) and puts cognitive load on the user.

Strategy 3: Auto-Merge (Best for Text)

If the conflicting edits touch different parts of the text — one user added a paragraph at the top, the other edited the middle — they can be automatically merged. Use a three-way merge algorithm: find the common ancestor (the last synced version), apply both sets of changes independently, and combine them.

plaintext
Ancestor:  "Meeting notes"
Version A: "Meeting notes\nAction items:\n- Review PRD"  (appended)
Version B: "**Meeting notes**"  (bolded title)
Merged:    "**Meeting notes**\nAction items:\n- Review PRD"  (both applied)

If the edits genuinely conflict (both edited the same sentence differently), fall back to Strategy 2 and surface the conflict to the user.

What I'd recommend: Strategy 3 with a fallback to Strategy 2. Auto-merge handles the majority of real-world conflicts silently; user resolution handles the hard cases. The implementation uses a CRDT-inspired approach or a Myers diff algorithm for text merging. This is what Notion and Apple Notes approximate in production.

Interviewer: What's a CRDT and when would you use one?

Candidate: A Conflict-free Replicated Data Type is a data structure designed to be merged from multiple sources without conflicts — mathematical guarantees, not heuristics. Every individual character insertion/deletion is tracked as an operation with a logical clock. Merging two CRDT documents always produces the same result regardless of order.

The trade-off: CRDT-encoded documents are significantly larger (each character has metadata), more complex to implement, and require a CRDT-aware server. For a simple notes app, the three-way merge approach is simpler and correct for most cases. CRDTs make sense if you're building real-time collaborative editing where multiple users type simultaneously — like a Notion or Google Docs clone. For a single user's notes syncing across their own devices, last-write-wins or three-way merge is almost always sufficient.


Delta Sync: Don't Send Everything

Interviewer: How does the client know which notes to pull from the server on sync?

Candidate: Delta sync. Rather than downloading every note on every sync, the client sends its last successful sync timestamp and the server returns only what changed since then.

plaintext
Client → GET /sync?since=1733990400000&deviceId=abc123
Server → {
    updated: [note1, note2],   // created or modified since that timestamp
    deleted: ["note_id_5"]     // deleted on another device since that timestamp
}

The client stores lastSyncTimestamp in DataStore<Preferences> — not Room. It's a scalar value, not structured data. DataStore is the modern replacement for SharedPreferences and avoids the thread-safety issues that SharedPreferences has when accessed from multiple coroutines.

On the server side, every note update touches a updated_at server timestamp that the delta query uses. Deletions are soft-deleted and tracked in a deleted_notes table so they appear in the delta response before the record is permanently purged.

If you want to practice explaining exactly this kind of trade-off — why DataStore over SharedPreferences, why delta sync over full refresh — under interview pressure, Mockingly.ai has Android system design simulations that put you in exactly these conversations.


Full-Text Search with Room FTS4

Interviewer: How does search work?

Candidate: Room's @Fts4 annotation creates a virtual table backed by SQLite's FTS4 extension. FTS4 tokenises text at insert time and maintains an inverted index, enabling fast prefix and phrase matching.

What FTS4 is: SQLite's Full-Text Search extension creates a separate virtual table alongside the main notes table. When a note is inserted or updated, FTS4 tokenises the text and updates its index. Queries use MATCH syntax rather than LIKE, and the lookup is O(log n) against the index rather than a full-table scan.

The DAO query:

kotlin
@Dao
interface NoteDao {
    @Query("""
        SELECT notes.* FROM notes
        INNER JOIN notes_fts ON notes.id = notes_fts.rowid
        WHERE notes_fts MATCH :query
          AND notes.isDeleted = 0
          AND notes.isArchived = 0
        ORDER BY rank
    """)
    fun searchNotes(query: String): Flow<List<NoteEntity>>
}

The caller appends * to enable prefix matching: "meet*" matches "meeting", "meetup", "meetings". Without the wildcard, FTS4 only matches exact tokens.

What Room FTS4 doesn't handle: fuzzy matching ("meetting" → "meeting"). If fuzzy search is important, consider maintaining a trigram index manually or preprocessing the query to expand common misspellings. For most notes apps, prefix matching is sufficient.


Attachments: Images and Files

Interviewer: How do you handle image attachments in notes?

Candidate: Never store image bytes in Room. SQLite is not an object store, and large blobs in a Room database degrade query performance for the entire database.

The pattern:

plaintext
User attaches photo to a note


Copy photo to app's internal private storage
  → /data/data/com.example.notes/files/attachments/{uuid}.jpg
  (Internal storage — not accessible to other apps, no storage permission needed)


Store only the local file path in Room:
  AttachmentEntity { id, noteId, localPath, remoteUrl, syncStatus }


On sync: upload file to remote storage (S3 / GCS) via pre-signed URL
  (same pattern as photo apps — direct upload, not through the app server)


Update AttachmentEntity.remoteUrl once uploaded

Why copy to internal storage? The original photo might live in the camera roll, accessible via a content URI. Content URIs are temporary and can be revoked if the user deletes the photo from the gallery. Copying to internal storage gives the notes app durable ownership of the attachment.

Attachment sync: attachments are uploaded asynchronously in a separate AttachmentSyncWorker. They don't block note text sync. Note text syncs fast (bytes); attachment upload can take seconds depending on file size and network speed. Separating them keeps note sync latency low and allows attachment upload to retry independently.

Storage quota: warn the user when attachment storage exceeds a configurable threshold (say, 500 MB). Offer a "clear cached attachments" option that deletes locally cached versions of remotely backed attachments — they can be re-downloaded on demand.


Sync Status UI

The UI should reflect sync state transparently without being intrusive.

Interviewer: How does the user know if their notes are synced?

Candidate: A SyncStatusTracker in the ViewModel exposes the aggregate sync state as a StateFlow:

kotlin
sealed class SyncState {
    object Synced : SyncState()
    object Syncing : SyncState()
    data class PendingCount(val count: Int) : SyncState()
    data class Conflict(val noteId: String) : SyncState()
    object Offline : SyncState()
}

The notes list screen observes this state and renders accordingly:

  • Synced: no indicator shown — clean state is the default
  • Syncing: a subtle progress indicator in the toolbar
  • PendingCount: "3 notes waiting to sync" — visible but not blocking
  • Conflict: a badge on the conflicted note's list item — red dot
  • Offline: "Working offline" banner at the top, non-blocking

The key UX principle from offline-first design: optimistic UI. Show the note as saved immediately. The "syncing" state is a background concern. Never block the user from editing while sync is in flight. The phrase "Saved locally — syncing" is more reassuring than a spinner that blocks typing.


Handling Edge Cases

Interviewer: What happens if the user deletes a note on their phone while it was being edited on their tablet?

Candidate: The delete event gets a serverTimestamp when it syncs. On the next sync to the tablet, the server sends a deleted array containing that note's ID. The tablet's sync worker checks: does this note have local unsaved changes (syncStatus = PENDING)?

If no local changes: apply the deletion. Room soft-deletes the note.

If yes, local PENDING changes: surface a conflict to the user. "This note was deleted on another device, but you have unsaved changes here. Keep your version or confirm deletion?" This is a case where auto-merge can't help — explicit user decision is required.

Interviewer: What about the note the user created offline that has the exact same UUID as one already on the server?

Candidate: UUID collision probability with a standard UUID.randomUUID() (Version 4 UUID) is approximately 1 in 2^122. With a billion notes created, the probability of any two sharing a UUID is roughly 1 in 10^27. For practical purposes this never happens. But defensively, the sync API validates UUID uniqueness server-side and returns a 409 Conflict if a collision somehow occurs. The client generates a new UUID and retries. This is a theoretical concern worth acknowledging but not worth over-engineering.


Common Interview Follow-ups

"How would you implement real-time collaboration — two users editing the same note simultaneously?"

This changes the architecture significantly. You'd need a WebSocket connection to a backend that broadcasts operations in real time. The local note model would switch to a CRDT (Conflict-free Replicated Data Type) like Yjs or Automerge, where every character insertion/deletion is represented as an operation with a logical timestamp. Room still holds the local state, but the sync mechanism becomes operation-based (send operations, receive operations) rather than state-based (send full note, receive full note). This is a significantly heavier lift than eventual-consistency sync — worth naming the architectural difference clearly.

"How do you handle the case where WorkManager can't sync because the device has been offline for weeks?"

The server retains notes in a soft-deleted state for a grace period (say, 90 days) before permanent deletion. Notes flagged as deleted by another device more than 90 days ago will simply not appear in delta sync responses. The client's eventual sync catchup handles all changes within that window correctly. If a device reconnects after the grace period, the user might see stale notes that others consider deleted — surface this edge case honestly in the design.

"How would you add a sharing feature — send a note to another user?"

Shared notes are a different data ownership model. A note owned by User A can be granted read or write access to User B. Server-side: a note_shares table with { note_id, owner_id, recipient_id, permission }. Client-side: the shared note appears in the recipient's note list via delta sync. The recipient's edits sync to the server, which fans out to the owner's devices on their next delta pull. Conflict resolution applies across users just as it does across devices.

"How do you prevent data loss if Room's database gets corrupted?"

Room's database file lives in internal storage — not backed up by default by Android's automatic backup. Enable android:allowBackup="true" and android:fullBackupContent rules in the manifest to include the Room database in Google's automatic cloud backup (limited to 25 MB). For production: implement explicit export — a background task that periodically exports all notes to a JSON file in the user's Google Drive or via Android's BlobStoreManager. Defense in depth: if the local database is corrupted, a full re-download from the server is the recovery path. This is why the server is the durable source of truth, not the device.


Quick Interview Checklist

  • ✅ Clarified scope — sync across devices, rich text, tags, search, full offline support
  • ✅ Clean Architecture + MVVM, Room as single source of truth
  • ✅ Client-generated UUIDs — offline note creation without server round-trip
  • syncStatus enum on every note — SYNCED, PENDING, CONFLICTED
  • ✅ Soft delete (isDeleted) — never hard-delete before sync
  • updatedAt and serverUpdatedAt — local and server timestamps for conflict detection
  • ✅ Rich text stored as JSON spans, plain text extracted separately for FTS
  • ✅ Room FTS4 for full-text search — @Fts4 annotation, MATCH query, prefix wildcard
  • ✅ Debounced saves in ViewModel — not on every keystroke, immediate save on onCleared()
  • ✅ WorkManager CoroutineWorker for background sync — persists across process kills and reboots
  • enqueueUniqueWork with KEEP policy — no duplicate sync workers
  • ✅ Three conflict strategies explained — LWW, user resolution, auto-merge with fallback
  • ✅ Delta sync with lastSyncTimestamp — not full re-download on every sync
  • lastSyncTimestamp in DataStore (not SharedPreferences, not Room)
  • ✅ Attachments copied to internal storage, file path in Room, uploaded separately
  • AttachmentSyncWorker separate from note text sync — different latency/retry profiles
  • ✅ Optimistic UI — "Saved locally, syncing" pattern
  • ✅ SyncState exposed as sealed class StateFlow from ViewModel
  • ✅ CRDT explained as the real-time collaboration upgrade path

Conclusion

Designing a notes app is a deceptively rich Android interview question. It's not about CRUD — it's about building a system that feels instant, never loses data, handles network absence gracefully, and reconciles state across devices in a way that makes sense to users.

The candidates who impress interviewers at Google, Microsoft, and Notion aren't the ones who describe a simple Room + Retrofit setup. They're the ones who name the offline-first principle explicitly, explain what syncStatus buys you, articulate the three conflict resolution strategies and their trade-offs, know why delta sync matters, and can describe what happens in the edit-on-two-devices-while-offline scenario without blinking.

The design pillars:

  1. Room as single source of truth — UI never talks to the network; all reads and writes go through Room
  2. Client-generated UUIDs — offline note creation requires a device-side identity
  3. syncStatus on every record — the engine that drives the entire sync pipeline
  4. WorkManager with unique work policy — the only tool that guarantees sync survives process kills
  5. Delta sync with DataStore timestamp — don't re-download everything on every sync
  6. Auto-merge with fallback to user resolution — the right conflict strategy for a notes app
  7. Room FTS4 for search — orders of magnitude faster than LIKE at any meaningful note count


Frequently Asked Questions

What is offline-first architecture in an Android notes app?

Offline-first means the app reads and writes to a local database — not a server — as its primary source of truth.

Every note operation (create, edit, delete) goes straight to Room without touching the network. The UI reflects changes instantly. Sync to the server happens in the background, invisible to the user.

Key properties of an offline-first notes app:

  1. All core features work with zero network connectivity
  2. UI never shows a loading spinner waiting for the network
  3. Data is never lost because the device lost signal mid-edit
  4. Server sync runs silently via WorkManager when connectivity returns

Why use Room as the single source of truth?

Room is the single source of truth because it guarantees the UI always has data — regardless of network state.

If the ViewModel fetched data from Retrofit directly, a network failure would leave the screen blank. With Room:

  1. The UI observes Room via Flow and reacts to every local change
  2. The network only writes into Room — it never drives the UI
  3. Cached data from the last session is available immediately on app open
  4. Offline and online states are identical from the UI's perspective

How does WorkManager handle background sync in a notes app?

WorkManager is Android's recommended API for guaranteed background work that must complete even after a process kill or device reboot.

How it handles notes sync:

  1. A NoteSyncWorker (CoroutineWorker) is enqueued with ExistingWorkPolicy.KEEP
  2. WorkManager persists the job to its own Room-backed database internally
  3. The job runs when the NetworkType.CONNECTED constraint is satisfied
  4. If the process is killed mid-sync, WorkManager re-runs the worker on next launch
  5. Multiple triggers (network return, app open, manual sync) never spawn duplicate workers

The KEEP policy is essential — without it, every app open could enqueue a fresh sync job, eventually queuing hundreds of redundant workers.


What is delta sync and why does it matter for a notes app?

Delta sync means uploading and downloading only what has changed since the last successful sync — not the entire note list every time.

How it works:

  1. The client stores lastSyncTimestamp in DataStore (not SharedPreferences — it's thread-safe)
  2. On each sync, the client sends this timestamp to the server
  3. The server returns only notes created or modified after that timestamp
  4. The client applies those changes to Room

Why it matters:

  • A user with 500 notes who edits 5 per day transfers ~25 KB, not ~2.5 MB
  • Sync is faster, uses less battery, and completes before the user notices

What is the syncStatus field and why does every note need one?

syncStatus is an enum on every NoteEntity that tracks whether the local state has been confirmed by the server.

The three states:

  1. SYNCED — local and server agree; no action needed
  2. PENDING — local change exists that hasn't been uploaded yet
  3. CONFLICTED — a conflict was detected that needs resolution

Why it's necessary:

  • The sync worker queries WHERE syncStatus = PENDING to know what to upload
  • The UI shows a badge on CONFLICTED notes for user attention
  • Without this field, the app has no reliable way to track unsent edits
  • On process kill mid-sync, PENDING notes are safely retried on next launch

What are the conflict resolution strategies for a syncing notes app?

A conflict occurs when the same note is edited on two devices while both are offline. There are three standard strategies:

1. Last-Write-Wins (LWW)

  • Compares updatedAt timestamps; the newer version wins
  • Pro: simple, zero user friction
  • Con: silently discards edits the user cares about
  • Best for: short notes where losing an edit is low-cost

2. User Resolution

  • Marks the note CONFLICTED, stores both versions, asks the user to choose
  • Pro: zero data loss
  • Con: adds friction; requires a diff/merge UI
  • Best for: apps where every word matters (legal, medical notes)

3. Auto-Merge with Fallback

  • Uses a three-way merge to combine changes touching different text ranges
  • Falls back to user resolution only when edits genuinely overlap
  • Pro: handles most real-world conflicts silently
  • Con: more complex to implement
  • Best for: general-purpose notes apps (Google Keep, Apple Notes style)

Recommended approach: auto-merge with a user-resolution fallback. Most conflicts are two edits to different paragraphs — they merge automatically. Genuine overlapping edits are surfaced explicitly.


How does Room FTS4 full-text search work in a notes app?

Room FTS4 creates a SQLite virtual table that tokenises note content at write time and enables instant keyword searches using an inverted index.

How it differs from a LIKE query:

  1. LIKE '%keyword%' scans every row sequentially — O(n)
  2. FTS4 MATCH 'keyword' is a direct index lookup — O(log n)
  3. FTS4 supports prefix matching (meet* matches "meeting", "meetup")
  4. Speed difference is imperceptible at 50 notes; significant at 5,000+

Setup in Room:

  1. Annotate a virtual entity with @Fts4(contentEntity = NoteEntity::class)
  2. Index title and contentPlainText (plain text stripped from rich text JSON)
  3. Query with SELECT * FROM notes INNER JOIN notes_fts WHERE notes_fts MATCH :query
  4. Append * to the query string to enable prefix matching

How do you store rich text in an Android notes app?

Rich text is serialised to JSON and stored in a contentJson TEXT column in Room. A separate contentPlainText column stores the stripped text for FTS indexing.

The JSON format:

json
{
  "text": "Meeting notes for Q4",
  "spans": [
    { "type": "BOLD", "start": 0, "end": 7 },
    { "type": "ITALIC", "start": 8, "end": 12 }
  ]
}

The two storage approaches compared:

ApproachProsCons
JSON spansNative Android integration, no library neededHarder to diff across devices
Markdown stringEasy to diff, portableRequires rendering library (Markwon)

For a Google Keep–style app with basic formatting, JSON spans integrating with Android's native SpannableString is the cleaner choice. For a Notion-style app with blocks and embeds, a block-based document model (similar to ProseMirror) is more appropriate.


Why are client-generated UUIDs important for offline note creation?

Client-generated UUIDs allow notes to have a stable identity from the moment of creation — before any server interaction.

If the primary key were a server-assigned integer:

  1. The app cannot create a note offline (no ID until the server responds)
  2. The UI must wait for a network round-trip before displaying the new note
  3. Process kill during creation leaves the app in an ambiguous state

With UUID.randomUUID() (Version 4):

  1. The note has an ID instantly — used as the Room PK and the server's document ID
  2. Offline creation, editing, and deletion all work without network
  3. The UUID serves as an idempotency key for sync — if a sync POST is retried, the server rejects the duplicate

Collision probability: ~1 in 10²⁷ with a billion notes. Effectively zero.


Which companies ask the Android notes app system design question?

Google, Microsoft, Notion, Evernote, and Dropbox all ask variants of this question for senior Android engineer roles.

Why it's popular:

  1. It's self-contained — clear product scope, no ambiguity about what to design
  2. It touches every hard Android topic: Room schema, WorkManager, offline-first, conflict resolution
  3. The depth scales cleanly — a junior candidate describes CRUD; a senior candidate explains syncStatus, delta sync, and three-way merge
  4. It maps directly to real products these companies build and maintain

Red flags interviewers watch for:

  1. Designing around Retrofit without mentioning offline handling
  2. Not explaining what syncStatus buys you
  3. Proposing SharedPreferences for lastSyncTimestamp (not thread-safe)
  4. Missing soft delete — hard-deleting before sync drops data silently

The notes app question rewards candidates who understand that mobile system design is fundamentally about managing two sources of truth — the device and the server — and making the reconciliation between them invisible to the user. If you want to practice talking through conflict resolution strategies, delta sync, and WorkManager lifecycle decisions under real interview pressure, Mockingly.ai has Android-focused system design simulations specifically built for engineers preparing for senior roles at Google, Microsoft, Notion, and Evernote.

Companies That Ask This

Ready to Practice?

You've read the guide — now put your knowledge to the test. Our AI interviewer will challenge you with follow-up questions and give you real-time feedback on your system design.

Free tier includes unlimited practice with AI feedback • No credit card required

Related System Design Guides

Design Reddit (Android)

A complete Android system design guide for designing Reddit — focused on what real interviews actually ask. Covers architecture, infinite feed with Paging 3 and RemoteMediator, cursor-based pagination, Room as single source of truth, optimistic voting with WorkManager rollback, offline strategy, image loading and memory management, nested comments, push notifications, and deep linking — in a real interview conversation format for senior Android engineers.

hard20 min

Design a Trading App like Coinbase / Bitvavo (Android)

A complete Android system design guide for designing a crypto trading app like Coinbase or Bitvavo. Covers Clean Architecture for financial apps, WebSocket channel management, real-time data pipeline with Kotlin Flow, order book rendering with throttled DiffUtil, candlestick chart architecture, Room schema for portfolio and trade history, offline resilience, price alert pipeline with WorkManager, and battery-aware subscriptions — all in a real interview conversation format for senior Android engineers.

hard22 min

Design a Photo Sharing App (Android)

A complete Android system design guide for designing a photo sharing app like Instagram. Covers Clean Architecture, image loading with Glide vs Coil, multi-layer caching, RecyclerView performance, photo upload pipeline with pre-signed URLs and WorkManager, feed pagination with Paging 3, offline support, media permissions across Android versions, and memory management — all in a real interview conversation format for senior Android engineers.

hard20 min