| name | kmp-offline-sync |
| description | Building an offline-first repository in KMP with Ktor + SQLDelight, including conflict resolution, background sync, and NetworkBoundResource-style flow. Use when designing features that must work without a connection. |
KMP Offline Sync
Instructions
Offline-first means the database is the source of truth. The UI observes the DB; sync jobs reconcile it with the server.
1. The NetworkBoundResource pattern
class ArticleRepositoryImpl(
private val api: ArticleApi,
private val dao: ArticleDao,
private val clock: Clock,
) : ArticleRepository {
override fun observeLatest(): Flow<List<Article>> = flow {
emitAll(dao.observeAll().onStart { launch { refreshIfStale() } })
}
override suspend fun refresh() {
val remote = api.latest(page = 1)
dao.upsertAll(remote.items.map { it.toEntity() })
}
private suspend fun refreshIfStale() {
val newest = dao.newestTimestamp() ?: Instant.DISTANT_PAST
if (clock.now() - newest > 5.minutes) runCatching { refresh() }
}
}
- UI always reads the DB flow; the network is a side effect.
- Failure to refresh doesn't block the flow — users still see cached data.
2. Mutations: write locally, enqueue sync
suspend fun createComment(articleId: Long, body: String) {
val pending = PendingComment(
id = Uuid.random().toString(),
articleId = articleId,
body = body,
createdAt = clock.now(),
syncState = SyncState.PENDING,
)
dao.insertComment(pending)
syncQueue.enqueue(SyncTask.CreateComment(pending.id))
}
The UI sees the comment immediately (optimistic). A background worker drains syncQueue.
3. SQLDelight-backed outbox
CREATE TABLE PendingOperation (
id TEXT PRIMARY KEY NOT NULL,
kind TEXT AS com.example.sync.OpKind NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
lastError TEXT,
createdAt INTEGER NOT NULL
);
nextBatch:
SELECT * FROM PendingOperation ORDER BY createdAt ASC LIMIT :limit;
incrementAttempts:
UPDATE PendingOperation SET attempts = attempts + 1, lastError = :err WHERE id = :id;
remove:
DELETE FROM PendingOperation WHERE id = :id;
4. The sync engine
class SyncEngine(
private val dao: SyncDao,
private val api: Api,
private val json: Json,
) {
suspend fun runOnce() = withContext(Dispatchers.IO) {
val batch = dao.nextBatch(limit = 20)
for (op in batch) runCatching {
when (op.kind) {
OpKind.CreateComment -> {
val payload = json.decodeFromString<CommentPayload>(op.payload)
api.createComment(payload.articleId, payload.body)
}
OpKind.DeleteComment -> api.deleteComment(json.decodeFromString<Long>(op.payload))
}
dao.remove(op.id)
}.onFailure {
dao.incrementAttempts(op.id, it.message ?: "unknown")
if (!it.isTransient()) dao.remove(op.id)
}
}
}
private fun Throwable.isTransient() =
this is NetworkException.Offline ||
this is NetworkException.Timeout ||
(this is NetworkException.Server)
5. Scheduling the sync
KMP does not provide cross-platform background schedulers. Use an interface and platform actuals:
interface SyncScheduler {
fun scheduleConnectivityBacked()
fun schedulePeriodic(interval: Duration)
fun cancelAll()
}
- Android: back with
WorkManager (OneTimeWorkRequest with NetworkType.CONNECTED constraint; PeriodicWorkRequest for every 15 min).
- iOS: back with
BGTaskScheduler (BGAppRefreshTaskRequest). Register the identifier in Info.plist.
- Desktop: a
CoroutineScope with a timer is usually enough.
6. Conflict resolution strategies
- Last-write-wins: server's
updatedAt supersedes local. Simple, fine for most user-generated content.
- Version field: server returns
409 with current version; client re-fetches, re-applies, re-sends.
- CRDTs: if you genuinely need concurrent edits, use Automerge — overkill for most apps.
Example LWW merge on refresh:
suspend fun mergeRemote(remote: List<ArticleDto>) = dao.transaction {
for (r in remote) {
val local = dao.byId(r.id)
if (local == null || local.updatedAt < r.updatedAt) {
dao.upsert(r.toEntity())
}
}
}
7. Connectivity observation
expect val connectivity: Flow<Boolean>
Android: ConnectivityManager.registerNetworkCallback. iOS: NWPathMonitor. When it flips to true, trigger syncEngine.runOnce().
8. Testing
- Use Ktor
MockEngine to simulate offline (respondError(HttpStatusCode.ServiceUnavailable) or throwing).
- Use SQLDelight in-memory driver.
- Assert that operations survive process death by instantiating a fresh repository over the same file-backed DB (in Android instrumented tests).
Checklist