Data Sync Between Phone and Tablet (Android)
Synchronization between one user's devices is a task where most architectural solutions fail under unstable connection. User created a record on phone, tablet was offline for hours, then reconnected—and data either duplicates or one variant silently overwrites the other.
Architectural Choice: Push vs Pull
Pull sync—device periodically requests changes from server. Simpler to implement via WorkManager with PeriodicWorkRequest, but data is always slightly stale. Suitable for non-critical data: notes, settings.
Push sync—server notifies devices of changes via FCM (Firebase Cloud Messaging). Device receives data payload with event type and changed object ID, then re-fetches data. Don't send data itself in push—FCM payload limit is 4 KB and delivery is not guaranteed.
Hybrid—push as trigger, pull as data fetch mechanism. This is production standard.
Conflicts on Simultaneous Editing
The hardest part. Strategies:
| Strategy | Description | When to use |
|---|---|---|
| Last Write Wins (LWW) | Record with later updated_at wins |
Simple data without critical losses |
| Server Wins | Local changes discarded on conflict | Server-controlled data |
| Client Wins | Local changes always applied | User notes, drafts |
| Merge | Field-level merging | Documents with independent fields |
| CRDT | Conflict-free Replicated Data Types | Real-time collaboration |
For most apps—LWW with metadata device_id and updated_at. Server stores latest version and timestamp, client on sync compares its updated_at with server's.
Implementation via Room + SyncAdapter or WorkManager
Room + WorkManager—modern approach without deprecated SyncAdapter:
@Entity(tableName = "notes")
data class Note(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val content: String,
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String = DeviceInfo.getDeviceId(),
val syncStatus: SyncStatus = SyncStatus.PENDING
)
enum class SyncStatus { SYNCED, PENDING, CONFLICT }
syncStatus = PENDING—record created/modified locally, not yet sent to server.
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val pendingNotes = noteDao.getPendingNotes()
pendingNotes.forEach { note ->
val serverNote = api.getNote(note.id)
when {
serverNote == null -> api.createNote(note)
serverNote.updatedAt > note.updatedAt -> {
// server is newer — update locally
noteDao.insert(serverNote.copy(syncStatus = SyncStatus.SYNCED))
}
else -> {
// local record is newer — send to server
api.updateNote(note)
noteDao.updateSyncStatus(note.id, SyncStatus.SYNCED)
}
}
}
// fetch changes from server for period
val serverChanges = api.getChangesSince(lastSyncTimestamp)
noteDao.insertAll(serverChanges.map { it.copy(syncStatus = SyncStatus.SYNCED) })
Result.success()
} catch (e: IOException) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
Delta Sync
Downloading all data on every sync is inefficient. Server stores cursor or checkpoint: time of last successful sync for each device. Client on request sends its lastSyncTimestamp, server returns only changes after that moment.
// SharedPreferences or Room
val lastSyncTimestamp = prefs.getLong("last_sync_${deviceId}", 0L)
val changes = api.getChangesSince(lastSyncTimestamp)
prefs.edit().putLong("last_sync_${deviceId}", System.currentTimeMillis()).apply()
Adaptive UI: Phone vs Tablet
Sync is not only data. On tablet two-pane layout is common (list + details), on phone—one-pane. When implementing via SlidingPaneLayout or NavigationSuiteScaffold (Compose), list and detail ViewModels can be different or shared—depending on mode. When transitioning phone to tablet (foldables), UI must adapt without state loss via WindowSizeClass.
Common Mistakes
Race condition on concurrent sync. Two devices send changes simultaneously—without idempotent operations on server (PUT /notes/{id} instead of POST), we get duplication. Server should return 200 on repeat PUT with same data.
Deleted records not synced. Soft delete is mandatory—is_deleted = true instead of physical deletion. Otherwise tablet won't know phone deleted record and restore it on next sync.
Implementing sync from scratch: 1–2 weeks for basic LWW strategy with push notifications. Complex merge-strategy scenarios—from one month. Cost is calculated individually after requirements analysis.







