Most apps need to synchronize a local database with a server. The fundamental choice is whether to fetch everything on each sync (full sync) or only what changed (delta sync).
Full Sync
Replace the local dataset entirely every sync cycle.
class FullSyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
val articles = api.getAllArticles() // fetch entire dataset
db.withTransaction {
dao.deleteAll()
dao.insertAll(articles.map { it.toEntity() })
}
prefs.lastSyncTime = System.currentTimeMillis()
Result.success()
} catch (e: IOException) {
Result.retry()
}
}
Pros:
- Simple to implement and reason about
- No server-side sync state required
- Correct by default — no stale items survive a sync
Cons:
- Transfer cost scales with dataset size, not change rate
- Battery and data usage grows with collection size
- Large datasets can't realistically be full-synced frequently
When to use: Datasets under ~500 items, or infrequent sync (e.g., daily config sync).
Delta Sync
Fetch only records changed since the last sync, identified by a server-side cursor or timestamp.
class DeltaSyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val lastSync = prefs.lastSyncCursor // could be a timestamp or opaque cursor
return try {
val response = api.getArticlesSince(cursor = lastSync)
db.withTransaction {
// Apply upserts and deletes from the delta
val (deleted, upserted) = response.changes.partition { it.deleted }
dao.deleteByIds(deleted.map { it.id })
dao.upsertAll(upserted.map { it.toEntity() })
}
prefs.lastSyncCursor = response.nextCursor // advance cursor
Result.success()
} catch (e: IOException) {
Result.retry()
}
}
}
Pros:
- Bandwidth proportional to change rate, not dataset size
- Much faster for large, slowly-changing datasets
- Suitable for large catalogs, message history
Cons:
- Server must maintain change log or support cursor-based queries
- Complex to implement correctly (cursor management, tombstones for deletes)
- Bugs can accumulate — local state can drift from server over time
Hybrid Strategy
Use delta sync regularly, full sync occasionally to correct drift:
fun scheduleSyncStrategy() {
val shouldFullSync = prefs.lastFullSyncTime?.let {
System.currentTimeMillis() - it > TimeUnit.DAYS.toMillis(7)
} ?: true // no prior full sync
if (shouldFullSync) {
enqueue(FullSyncWorker::class)
} else {
enqueue(DeltaSyncWorker::class)
}
}
Server-Side Requirements for Delta Sync
| Feature | Implementation |
|---|---|
| Change log | Append-only table of all mutations with a sequence number |
| Soft deletes | deleted_at column instead of DELETE — so clients know to remove the item |
| Cursor | Sequence number or ISO timestamp the client sends on the next request |
| Tombstones | Records marked as deleted so clients know to remove them locally |
Example API contract:
GET /api/articles?since=1690000000000
{
"changes": [
{ "id": "a1", "title": "Updated", "deleted": false, "updatedAt": 1690001234567 },
{ "id": "a2", "deleted": true }
],
"nextCursor": "1690001234567"
}
Decision Matrix
| Factor | Use Full Sync | Use Delta Sync |
|---|---|---|
| Dataset size | < 500 rows | > 500 rows |
| Change frequency | High (most items change) | Low (few items change) |
| Server control | Limited (can't add change log) | Full control |
| Implementation effort | Low | Medium–High |
| Correctness guarantees | Strong | Needs occasional full sync |