Implementing Offline Data Synchronization with Server
Synchronization is not just "load data on startup." It's a bidirectional process: client accumulates changes offline, server accumulates changes from other clients, on reconnection both must reach consistent state. Proper sync architecture is one of the most technically complex tasks in mobile development.
Delta Sync vs Full Sync
Most obvious implementation — on network recovery, request all data again. Works on small volumes. With 10,000 records downloading full list each time wastes traffic, time, and server load.
Delta sync — client sends lastSyncTimestamp, server returns only changed and deleted records since then.
data class SyncRequest(
val lastSyncTimestamp: Long,
val clientId: String
)
data class SyncResponse(
val serverTimestamp: Long, // response timestamp
val updated: List<ProductDto>, // changed or new
val deletedIds: List<String> // deleted on server
)
On client, save lastSuccessfulSyncTimestamp in MMKV or SharedPreferences. Next sync, use it as filter.
Important: time must be server time. If client uses its own time, clock skew causes misses. Server sends its timestamp in response — client saves exactly that.
SyncManager Architecture
class SyncManager(
private val api: SyncApi,
private val dao: ProductDao,
private val pendingOpsDao: PendingOperationDao,
private val prefs: SyncPreferences
) {
suspend fun sync(): SyncResult {
// 1. Upload accumulated offline operations
val pending = pendingOpsDao.getAll()
if (pending.isNotEmpty()) {
try {
val uploadResult = api.uploadOperations(pending.map { it.toRequest() })
// Delete only successfully processed
pendingOpsDao.deleteByIds(uploadResult.processedIds)
// Failed ones stay in queue
} catch (e: NetworkException) {
return SyncResult.NetworkError
}
}
// 2. Download server changes
return try {
val response = api.sync(
SyncRequest(
lastSyncTimestamp = prefs.lastSyncTimestamp,
clientId = prefs.clientId
)
)
dao.applyDelta(
updated = response.updated.map { it.toEntity() },
deletedIds = response.deletedIds
)
prefs.lastSyncTimestamp = response.serverTimestamp
SyncResult.Success(
updatedCount = response.updated.size,
deletedCount = response.deletedIds.size
)
} catch (e: Exception) {
SyncResult.Error(e)
}
}
}
applyDelta in transaction — atomic. Either apply all or nothing:
@Transaction
suspend fun applyDelta(updated: List<ProductEntity>, deletedIds: List<String>) {
upsertAll(updated)
softDeleteByIds(deletedIds, System.currentTimeMillis())
}
Soft delete mandatory: don't physically delete, set is_deleted = true flag and save timestamp. Otherwise, next delta sync we "forget" this deletion.
Sync Triggers
Launch sync in several scenarios:
class SyncScheduler(
private val workManager: WorkManager,
private val syncManager: SyncManager,
private val networkMonitor: NetworkMonitor
) {
init {
// Periodic background sync
val periodicSync = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.build()
workManager.enqueueUniquePeriodicWork(
"periodic-sync",
ExistingPeriodicWorkPolicy.KEEP,
periodicSync
)
}
// On network recovery — immediate sync
fun observeNetworkAndSync() {
networkMonitor.isOnline
.filter { it } // only offline→online transition
.distinctUntilChanged()
.onEach { triggerImmediateSync() }
.launchIn(applicationScope)
}
// On app foreground
fun onAppForeground() {
val lastSync = prefs.lastSyncTimestamp
val tooOld = System.currentTimeMillis() - lastSync > 5 * 60 * 1000L
if (tooOld) triggerImmediateSync()
}
}
iOS equivalent of WorkManager — BGAppRefreshTask and BGProcessingTask. Background tasks run at OS discretion and are time-limited (30 seconds for appRefresh, up to 3 minutes for processing).
Image and File Synchronization
Binary data separate from metadata. Sync file list (names, URLs, hashes), download files via separate requests with prioritization:
class MediaSyncManager {
suspend fun syncMedia(mediaList: List<MediaMeta>) {
val toDownload = mediaList.filter { meta ->
!fileCache.exists(meta.localPath) ||
fileCache.getHash(meta.localPath) != meta.serverHash
}
// Download in parallel, but limit concurrency
toDownload.chunked(4).forEach { batch ->
batch.map { meta ->
async { downloadFile(meta) }
}.awaitAll()
}
}
}
Chunks of 4 don't overload connection, on link loss we lose max 4 files from current batch.
Sync State in UI
User should see data freshness. Minimum: last sync timestamp. Better: status icon (synced / syncing / sync error) next to data that might be stale.
On sync error — don't block UI. Show warning, allow work with local data, offer retry.
Full bidirectional delta-sync implementation with operation queue and conflict handling: 4–8 weeks depending on data volume and entity types. Cost calculated individually.







