Data Synchronization in Mobile Apps
I once shipped a sync bug that merged one user's grocery list with another's. Someone ended up with "diapers, bourbon, dog food, 12 limes." The support ticket was funny. The root cause was not — a missing user ID filter on the delta sync query. Three days to track down, two minutes to fix.
Sync bugs are uniquely painful because they're silent. The app doesn't crash. It just shows the wrong data, and nobody notices until it's too late. That's why interviewers love asking about sync — it tests whether you understand distributed systems at the client level, and whether you've thought about what happens when things go wrong.
Why Sync Is Hard on Mobile#
Unreliable networks. Mobile devices move between WiFi, LTE, 3G, and dead zones. A sync operation can start on WiFi and lose connectivity mid-transfer. Your sync logic needs to handle partial transfers, timeouts, and retries without corrupting data.
The app can die at any moment. The OS can kill your app to reclaim memory. The user can force-quit it. If your sync was halfway through writing changes to the local database, those writes had better be atomic.
Multiple devices, one account. A user edits a note on their phone, then opens the same note on their tablet. Both devices may have made changes while the other was offline. Now you need to reconcile two versions of the same data.
Server-side changes. While the client was offline, other users or backend jobs may have modified the same data the client has cached locally. Your client can't assume its local state is still valid.
These aren't edge cases. For any app with offline support, these are Tuesday.
Online-First vs Offline-First#
The first design decision you'll make — and the one you should state explicitly in an interview — is whether your app is online-first or offline-first.
Online-First#
The server is the source of truth. The local database is a cache. When the user makes a change, the app sends it to the server immediately. If there's no connectivity, the change either fails or gets queued for a short retry window.
This is the right choice when data accuracy matters more than availability. A stock trading app is online-first — you can't let someone buy shares based on stale prices and sync later. A banking app is the same. The cost of acting on outdated data is too high.
Offline-First#
The local database is the primary data store. The user can read and write freely without connectivity. Changes sync to the server whenever a connection is available. The app works perfectly in airplane mode.
This is the right choice when availability matters more than immediate consistency. A notes app like Bear or Apple Notes is offline-first. So is a task manager like Todoist. Users expect to jot things down on the subway and have it sync later.
Comparison#
| Online-First | Offline-First | |
|---|---|---|
| Source of truth | Server | Local database |
| Offline writes | Blocked or short retry | Fully supported |
| Conflict risk | Low (server always wins) | High (multiple sources of writes) |
| Implementation complexity | Lower | Significantly higher |
| Data freshness | Always current | Eventually consistent |
| Best for | Trading, banking, live auctions | Notes, messaging, task management |
| Example apps | Robinhood, Venmo | Notion, Todoist, iMessage |
Interview Tip: State your choice early and justify it. "For a notes app, I'd go offline-first because users expect to write notes without connectivity, and eventual consistency is acceptable. For a ride-hailing app, I'd go online-first because showing stale driver locations would break the user experience." Interviewers want to see that you understand the tradeoff, not that you memorized one approach.
Pull-Based Sync#
The simplest sync strategy: the client asks the server "what changed?" on a schedule or in response to user actions.
Basic Polling#
The client hits a sync endpoint every N seconds (or minutes) and pulls down changes. This is easy to implement and easy to reason about. It's also wasteful — most polls return nothing.
// Android: Simple polling sync with coroutines
class SyncScheduler(
private val syncRepository: SyncRepository,
private val scope: CoroutineScope
) {
private var syncJob: Job? = null
fun startPolling(intervalMs: Long = 30_000) {
syncJob?.cancel()
syncJob = scope.launch {
while (isActive) {
try {
syncRepository.pullChanges()
} catch (e: IOException) {
// Network error — skip this cycle, try again next interval
}
delay(intervalMs)
}
}
}
fun stopPolling() {
syncJob?.cancel()
}
}
// iOS: Simple polling sync with structured concurrency
class SyncScheduler {
private let syncRepository: SyncRepository
private var syncTask: Task<Void, Never>?
init(syncRepository: SyncRepository) {
self.syncRepository = syncRepository
}
func startPolling(interval: TimeInterval = 30) {
syncTask?.cancel()
syncTask = Task {
while !Task.isCancelled {
do {
try await syncRepository.pullChanges()
} catch {
// Network error — skip this cycle, try again next interval
}
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
}
}
}
func stopPolling() {
syncTask?.cancel()
}
}
Polling intervals are a tradeoff. Poll every 5 seconds and you're burning battery and data for mostly-empty responses. Poll every 5 minutes and the user sees stale data. For most apps, 30 seconds to 2 minutes is a reasonable starting point, with an immediate sync triggered on app foreground.
Delta Sync#
Polling gets smarter when you only fetch what changed since the last sync. Instead of pulling the entire dataset every time, the client sends a timestamp or version number, and the server returns only records modified after that point.
// Android: Delta sync using last sync timestamp
class SyncManager(
private val prefs: SharedPreferences,
private val apiClient: ApiClient,
private val localStore: LocalStore
) {
private var lastSyncTimestamp: Long
get() = prefs.getLong("lastSyncTimestamp", 0L)
set(value) = prefs.edit().putLong("lastSyncTimestamp", value).apply()
suspend fun pullChanges() {
val changes = apiClient.fetchChanges(since = lastSyncTimestamp)
localStore.applyChanges(changes)
changes.maxOfOrNull { it.updatedAt }?.let { latestTimestamp ->
lastSyncTimestamp = latestTimestamp
}
}
}
// iOS: Delta sync using last sync timestamp
class SyncManager {
private let defaults = UserDefaults.standard
private let apiClient: APIClient
private let localStore: LocalStore
private var lastSyncTimestamp: Date {
get { defaults.object(forKey: "lastSyncTimestamp") as? Date ?? .distantPast }
set { defaults.set(newValue, forKey: "lastSyncTimestamp") }
}
func pullChanges() async throws {
let changes = try await apiClient.fetchChanges(since: lastSyncTimestamp)
try localStore.applyChanges(changes)
if let latestTimestamp = changes.map(\.updatedAt).max() {
lastSyncTimestamp = latestTimestamp
}
}
}
Delta sync reduces payload sizes dramatically, but watch out for clock skew. If the server runs across multiple machines with slightly different clocks, you can miss changes or process duplicates. Server-generated monotonic version numbers are safer than timestamps for this reason.
Interview Tip: If you mention delta sync, follow up with how you'd handle the initial sync — the very first time the app launches with an empty database. Usually that's a paginated full fetch, after which you switch to delta mode. Interviewers like seeing that you've thought about the cold-start case.
Push-Based Sync#
Instead of the client asking "anything new?", the server tells the client when something changes. This is more efficient but adds moving parts.
Push Notifications (Silent Pushes)#
A silent push notification wakes the app and triggers a sync. The user doesn't see the notification — it's a signal to the app to pull fresh data. This is battery-efficient because the push infrastructure is managed by the OS, but you're limited in how much background time you get (especially on iOS, where the OS can throttle or skip silent pushes entirely).
Good for: apps that need "pretty fresh" data but not real-time. Email apps use this pattern.
WebSockets#
A persistent bidirectional connection between client and server. Changes are pushed instantly. This gives you real-time updates but costs battery because the connection stays open.
Good for: messaging apps, collaborative editing, live dashboards — anything where a 30-second delay is unacceptable.
Server-Sent Events (SSE)#
A one-way persistent connection from server to client. Simpler than WebSockets (no bidirectional protocol upgrade), works over standard HTTP, and reconnects automatically. The downside: you can only push data from server to client, not the other way.
Good for: live feeds, notification streams, or any case where the server pushes updates but the client submits changes through normal REST calls.
Which to Pick#
In practice, most production apps combine strategies. A messaging app might use WebSockets when the app is foregrounded, fall back to silent push notifications when backgrounded, and do a full delta sync on each app launch to catch anything that was missed.
In an interview, stating this hybrid approach shows maturity. Don't pick one mechanism and pretend it solves everything.
Conflict Resolution#
This is where sync gets genuinely hard. Two devices edit the same record while both are offline. They come back online and sync. Now you have two different versions of the same data. What do you do?
Last-Write-Wins (LWW)#
The simplest strategy: whichever write has the later timestamp wins. The other is discarded.
This is fine for data where losing an edit is acceptable. Profile photo changes, status updates, settings toggles — if the user changed their display name on two devices, keeping the most recent one is probably what they wanted anyway.
It's not fine for data where losing edits is painful. If two people edited different paragraphs of a shared document, throwing away one person's work because their device had a slower clock is a bad outcome.
Field-Level Merge#
Instead of replacing the entire record, merge at the field level. If Device A changed the title and Device B changed the body, keep both changes. Only flag a conflict when both devices changed the same field.
// Android: Field-level merge for a Note entity
data class Note(
val id: String,
val title: String,
val body: String,
val tags: List<String>,
val updatedAt: Long
)
fun mergeNotes(local: Note, remote: Note, base: Note): MergeResult {
// Compare each field against the common ancestor (base)
val mergedTitle = when {
local.title != base.title && remote.title == base.title -> local.title
remote.title != base.title && local.title == base.title -> remote.title
local.title == remote.title -> local.title
else -> return MergeResult.Conflict(local, remote, field = "title")
}
val mergedBody = when {
local.body != base.body && remote.body == base.body -> local.body
remote.body != base.body && local.body == base.body -> remote.body
local.body == remote.body -> local.body
else -> return MergeResult.Conflict(local, remote, field = "body")
}
return MergeResult.Success(
local.copy(title = mergedTitle, body = mergedBody)
)
}
sealed class MergeResult {
data class Success(val merged: Note) : MergeResult()
data class Conflict(val local: Note, val remote: Note, val field: String) : MergeResult()
}
// iOS: Field-level merge for a Note entity
struct Note {
let id: String
var title: String
var body: String
var tags: [String]
var updatedAt: Date
}
func mergeNotes(local: Note, remote: Note, base: Note) -> MergeResult {
// Compare each field against the common ancestor (base)
let mergedTitle: String
if local.title != base.title && remote.title == base.title {
mergedTitle = local.title
} else if remote.title != base.title && local.title == base.title {
mergedTitle = remote.title
} else if local.title == remote.title {
mergedTitle = local.title
} else {
return .conflict(local: local, remote: remote, field: "title")
}
let mergedBody: String
if local.body != base.body && remote.body == base.body {
mergedBody = local.body
} else if remote.body != base.body && local.body == base.body {
mergedBody = remote.body
} else if local.body == remote.body {
mergedBody = local.body
} else {
return .conflict(local: local, remote: remote, field: "body")
}
var merged = local
merged.title = mergedTitle
merged.body = mergedBody
return .success(merged: merged)
}
enum MergeResult {
case success(merged: Note)
case conflict(local: Note, remote: Note, field: String)
}
This requires keeping a "base" version — the last known common state before the divergence. Without it, you can't tell who changed what.
Operational Transforms and CRDTs#
For collaborative real-time editing (think Google Docs or Figma), you need something stronger. Two approaches come up in interviews:
Operational Transforms (OT): Model each change as an operation (insert character at position 5, delete characters 3-7). When concurrent operations arrive, transform them so they produce the same result regardless of application order. This is what Google Docs uses. It works, but the transformation logic is notoriously tricky.
CRDTs (Conflict-Free Replicated Data Types): Data structures mathematically guaranteed to converge. Two devices apply changes independently, and when they merge, the result is consistent — no conflict resolution needed. Examples: G-Counters, LWW-Registers, OR-Sets.
You don't need to implement a CRDT in an interview. But mentioning them when asked "Design a collaborative notes app" — and explaining that they guarantee convergence without a central server — shows you know the space.
Interview Tip: When discussing conflict resolution, match the strategy to the data type. "For user profile fields, last-write-wins is fine. For the shared shopping list, I'd use an add-wins set so that items added on either device always show up — I'd rather have a duplicate than a missing item. For the rich text editor, I'd mention CRDTs but note that implementing one is a project in itself."
The Sync Queue / Outbox Pattern#
When the user makes a change offline, you can't send it to the server immediately. The outbox pattern queues changes locally and processes them in order when connectivity returns.
// Android: Sync outbox with persistent queue
@Serializable
data class PendingChange(
val id: String,
val entityType: String,
val entityId: String,
val operation: ChangeOperation,
val payload: ByteArray,
val createdAt: Long,
val retryCount: Int = 0
)
@Serializable
enum class ChangeOperation {
CREATE, UPDATE, DELETE
}
class SyncOutbox(
private val store: PendingChangeStore // Backed by Room
) {
fun enqueue(change: PendingChange) {
store.insert(change)
}
suspend fun processQueue(apiClient: ApiClient) {
val pending = runCatching { store.fetchAllOrderedByCreatedAt() }.getOrDefault(emptyList())
for (change in pending) {
try {
apiClient.submit(change)
store.delete(change.id)
} catch (e: ApiException) {
if (e.isRetryable) {
// Network timeout, server 503 — leave in queue, bump retry count
store.incrementRetryCount(change.id)
if (change.retryCount >= 5) {
store.markFailed(change.id)
}
break // Stop processing — order matters
} else {
// Non-retryable error (400, 409) — the server rejected this change
store.markFailed(change.id)
// Don't break — try the next change
}
}
}
}
}
// iOS: Sync outbox with persistent queue
struct PendingChange: Codable {
let id: UUID
let entityType: String
let entityId: String
let operation: ChangeOperation
let payload: Data
let createdAt: Date
var retryCount: Int = 0
}
enum ChangeOperation: String, Codable {
case create, update, delete
}
class SyncOutbox {
private let store: PendingChangeStore // Backed by local DB
func enqueue(_ change: PendingChange) throws {
try store.insert(change)
}
func processQueue(using apiClient: APIClient) async {
let pending = try? store.fetchAll(orderedBy: .createdAt)
for change in pending ?? [] {
do {
try await apiClient.submit(change)
try? store.delete(change.id)
} catch let error as APIError where error.isRetryable {
// Network timeout, server 503 — leave in queue, bump retry count
try? store.incrementRetryCount(change.id)
if change.retryCount >= 5 {
try? store.markFailed(change.id)
}
break // Stop processing — order matters
} catch {
// Non-retryable error (400, 409) — the server rejected this change
try? store.markFailed(change.id)
// Don't break — try the next change
}
}
}
}
A few things to notice in this design:
Order matters. If the user created a folder and then moved a file into it, those changes must be submitted in order. If the "move file" request arrives before the "create folder" request, the server will reject it because the folder doesn't exist yet. That's why we break on retryable errors — we can't skip ahead.
Non-retryable errors are tricky. If the server returns a 409 Conflict, you need a strategy: silently drop the change, surface an error to the user, or store the conflict for manual resolution. The right choice depends on your app.
The queue must be persistent. If you store pending changes in memory, they vanish when the app is killed. Use the local database.
Optimistic vs Pessimistic Updates#
This is about what the user sees while the sync is happening.
Optimistic Updates#
Show the change immediately in the UI, before the server confirms it. If the server later rejects the change, roll it back.
Instagram likes are optimistic. You tap the heart, it fills in red instantly. The app fires off a request in the background. If the request fails (rare), the heart quietly goes back to empty. The user barely notices.
This works well when: (1) the server almost never rejects the change, (2) rolling back is cheap, and (3) speed matters for the user experience.
Pessimistic Updates#
Wait for the server to confirm before showing the change in the UI. Show a loading state while waiting.
Bank transfers are pessimistic. You tap "Send $500" and see a spinner until the server confirms the transaction went through. Nobody wants their balance to show $500 less, only to find out the transfer failed and the money is still there — or worse, to show the money still there when it's actually gone.
This works well when: (1) the change has real-world consequences that can't be undone, (2) the server may reject the change for business reasons (insufficient funds, permissions), or (3) showing incorrect state is worse than a loading spinner.
Most apps use a mix. Checking off a task? Optimistic. Deleting an entire project? Pessimistic.
Partial Sync and Pagination#
You can't sync everything. A user with 10,000 notes and 50,000 messages doesn't need all of that on their device right now. You need a strategy for syncing a subset.
Recency-based sync. Sync the last 30 days of data on first install, then sync incrementally going forward. If the user scrolls back to older content, fetch it on demand.
Priority-based sync. Sync starred/pinned items first, then recent items, then everything else.
On-demand sync. Don't sync old data at all until the user navigates to it. A messaging app might only sync the conversation list and the 3 most recent conversations on first launch. Older conversations load when tapped.
Handling gaps. If you only synced recent data, the user might scroll into a region with no local data. Show a loading state, fetch the missing data, and stitch it into the local database. Same problem as infinite scroll pagination, applied to the sync layer.
Putting It Together: Designing a Collaborative Notes App#
Let me walk through the actual sync design decisions for a collaborative notes app — the kind of question you'd get in an interview.
Requirements (Assumed After Gathering)#
- Users create and edit notes. Notes can be shared with other users for collaboration.
- The app should work offline. Users can create and edit notes without connectivity.
- Changes sync when the device comes back online.
- If two users edit the same note while both are offline, the app should handle it gracefully.
- Target: both iOS and Android.
Decision 1: Online-First or Offline-First?#
Offline-first. The whole point of a notes app is that you can jot something down whenever you want. Blocking writes on connectivity would break the core use case.
Decision 2: Sync Direction and Trigger#
Bidirectional sync. The client pushes local changes to the server, and pulls remote changes from the server. Sync triggers:
- On app foreground (catch up on missed changes)
- After any local write (push changes immediately if online)
- On a 60-second polling interval while the app is active
- Via silent push notification when another user edits a shared note
Decision 3: Delta Sync Mechanism#
Each note has a version field (server-incremented integer). The client tracks lastSyncVersion globally. On sync, the client asks for all notes where version > lastSyncVersion, applies them locally, and bumps the version.
Decision 4: Conflict Resolution#
For single-user notes (not shared), last-write-wins on the field level is sufficient. The user is only competing with themselves across devices, and field-level merge covers most cases.
For shared notes, I'd use field-level merge with a three-way diff (last-synced version as the common ancestor). Both users edited different fields? Merge automatically. Same field? Flag the conflict for the user to resolve. For a v2, CRDTs could enable character-level merging of the note body — but I'd call that out as a future improvement rather than designing it in the interview.
Decision 5: Sync Queue#
All local writes go into a persistent outbox, processed in order. If the server returns a 409, the engine fetches the server's version, runs the merge logic, and resubmits.
// Android: Simplified sync engine for the notes app
class NotesSyncEngine(
private val outbox: SyncOutbox,
private val localDB: NotesDatabase,
private val api: NotesApi
) {
private var lastSyncVersion: Int = 0
suspend fun sync() {
// Step 1: Push local changes
outbox.processQueue(api)
// Step 2: Pull remote changes
try {
val remoteChanges = api.fetchChanges(since = lastSyncVersion)
for (remoteNote in remoteChanges) {
val localNote = localDB.fetch(id = remoteNote.id)
if (localNote != null) {
// Note exists locally — check for conflicts
if (localNote.hasUnpushedChanges) {
val base = localDB.fetchBaseVersion(id = remoteNote.id)
val result = mergeNotes(local = localNote, remote = remoteNote, base = base)
localDB.save(result)
} else {
// No local changes — just accept the remote version
localDB.save(remoteNote)
}
} else {
// New note from server — insert locally
localDB.insert(remoteNote)
}
}
lastSyncVersion = remoteChanges.maxOfOrNull { it.version } ?: lastSyncVersion
} catch (e: Exception) {
// Sync failed — will retry next cycle
}
}
}
// iOS: Simplified sync engine for the notes app
class NotesSyncEngine {
private let outbox: SyncOutbox
private let localDB: NotesDatabase
private let api: NotesAPI
private var lastSyncVersion: Int
func sync() async {
// Step 1: Push local changes
await outbox.processQueue(using: api)
// Step 2: Pull remote changes
do {
let remoteChanges = try await api.fetchChanges(since: lastSyncVersion)
for remoteNote in remoteChanges {
if let localNote = try? localDB.fetch(id: remoteNote.id) {
// Note exists locally — check for conflicts
if localNote.hasUnpushedChanges {
let base = try? localDB.fetchBaseVersion(id: remoteNote.id)
let result = mergeNotes(local: localNote, remote: remoteNote, base: base)
try localDB.save(result)
} else {
// No local changes — just accept the remote version
try localDB.save(remoteNote)
}
} else {
// New note from server — insert locally
try localDB.insert(remoteNote)
}
}
lastSyncVersion = remoteChanges.map(\.version).max() ?? lastSyncVersion
} catch {
// Sync failed — will retry next cycle
}
}
}
Decision 6: Optimistic Updates#
All local writes are optimistic. Changes appear instantly in the UI and get pushed to the server in the background. If a conflict arises, the user sees a conflict resolution UI — rare enough that it doesn't hurt the normal experience.
What This Demonstrates#
You've justified offline-first, structured bidirectional sync, handled conflicts at two levels, used the outbox pattern for reliable writes, and made practical choices (CRDTs as v2, not v1). Interviewers aren't looking for a perfect system. They're looking for a well-reasoned one.
Common Interview Mistakes Around Sync#
Ignoring sync entirely. If the question involves offline support, you need a sync strategy. "We'll just cache data locally" is not a sync strategy — it's a read cache. What about writes?
Over-engineering. Reaching for CRDTs when last-write-wins would be fine. Match the complexity of your solution to the complexity of the problem.
Forgetting the initial sync. Delta sync is great for subsequent syncs, but what happens the very first time the app opens? You need a strategy for bootstrapping the local database — usually a paginated full fetch.
Not addressing failures. "The outbox processes changes in order" is good. "And if the server rejects a change, we mark it as failed and skip to the next one" is better. "And we surface unresolvable conflicts to the user with a merge UI" is best.
Treating sync as one-directional. Unless the app is read-only, sync is bidirectional. Push local changes and pull remote changes. State both directions.
The Bottom Line#
Sync is not one problem — it's a family of problems that interact in subtle ways. The best approach in an interview is to decompose it: state your online-first vs offline-first choice, describe how changes flow in both directions, explain your conflict resolution strategy, and show how you handle failures.
You don't need to build Google Docs in 45 minutes. You need to show that you understand why sync is hard, and that you can make reasonable tradeoffs for the specific app you're designing. That's enough to clear the bar.