Offline-first architecture for Kotlin Multiplatform apps — local-first reads, background sync, optimistic updates, conflict resolution, and sync state tracking. Covers: single-source-of-truth via SQLDelight, a SyncManager pattern using WorkManager/BGTaskScheduler, optimistic mutation with rollback, and a SyncState sealed class surfaced to the UI via MVI. Does NOT cover FCM-triggered sync (see push-notifications) or RPC streaming (see kotlin-rpc).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ronjunevaldoz/kmp-agent-skills --skill kmp-offline-first --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Kmp Offline First?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ronjunevaldoz-kmp-offline-first)More formats (shields.io, HTML) on the badges page.
---
name: kmp-offline-first
description: >
Offline-first architecture for Kotlin Multiplatform apps — local-first reads,
background sync, optimistic updates, conflict resolution, and sync state tracking.
Covers: single-source-of-truth via SQLDelight, a SyncManager pattern using
WorkManager/BGTaskScheduler, optimistic mutation with rollback, and a SyncState
sealed class surfaced to the UI via MVI. Does NOT cover FCM-triggered sync
(see push-notifications) or RPC streaming (see kotlin-rpc).
license: Apache-2.0
metadata:
author: kmp-agent-skills
last-updated: '2026-06-21'
keywords:
- offline first
- offline-first
- local first
- sync
- optimistic update
- conflict resolution
- background sync
- single source of truth
- cache
- network-cache-flow
---
## When to Use This Skill
Use this skill when:
- The app must show data even with no network connection
- The user should see their own changes immediately, before the server confirms them
- A background job needs to push local changes to the server when connectivity returns
- You need to surface sync status (syncing, synced, error, conflict) in the UI
Do NOT use this skill when:
- The app is network-only and an error state is acceptable with no connectivity
- Real-time streaming is the requirement — use `kotlin-rpc` or WebSockets instead
> **Opt-in skill — only when explicitly requested.** Do NOT activate for generic data terms
> like "cache", "data consistency", "single source of truth", "sync", or "data redundancy" —
> those belong to `repository-pattern` (data layer + fetch strategy) or `sqldelight-setup`
> (local persistence). Offline-first adds the heavier `SyncManager` + `WorkManager`/`BGTaskScheduler`
> machinery and must be **named explicitly** before it is selected.
**Trigger keywords:** offline first, offline-first, local first, optimistic update,
conflict resolution, conflict handling, background sync, background data sync,
cache-then-network, SyncManager, SyncState.
**Freshness rule:** `WorkManager` and `BGTaskScheduler` APIs change — recheck
[WorkManager releases](https://developer.android.com/jetpack/androidx/releases/work)
and [BGTaskScheduler docs](https://developer.apple.com/documentation/backgroundtasks)
before implementing. Also verify the SQLDelight version in `libs.versions.toml` matches
the one in the `kmp-sqldelight-setup` skill.
---
## Recommendation First
Default to **local-first reads with background sync**:
1. `:data` always reads from the local SQLDelight database — never directly from network
2. A `SyncManager` fetches from the network and writes to the database; the UI observes
the database `Flow` and gets the update automatically
3. Mutations are written locally first (optimistic), then synced in the background
```kotlin
// ✅ Correct — read from DB, sync in background
class UserRepositoryImpl(
private val db: AppDatabase,
private val api: UserApiService,
private val sync: SyncManager,
) : UserRepository {
override fun observeUser(id: UserId): Flow<User> =
db.userQueries.selectById(id.value).asFlow().mapToOne(Dispatchers.IO)
.onStart { sync.requestSync(SyncTag.USER) }
override suspend fun updateName(id: UserId, name: String) {
db.userQueries.updateName(name = name, id = id.value) // optimistic
sync.requestSync(SyncTag.USER) // background push
}
}
```
---
## Sync State
Track sync status as a first-class value in `commonMain`:
```kotlin
// :model
sealed class SyncState {
data object Idle : SyncState()
data object Syncing : SyncState()
data object Synced : SyncState()
data class Error(val message: String, val retryable: Boolean) : SyncState()
data class Conflict(val localVersion: Any, val remoteVersion: Any) : SyncState()
}
enum class SyncTag(val id: String) {
USER("sync:user"),
FEED("sync:feed"),
ALL("sync:all"),
}
```
Expose it through the ViewModel's `UiState`:
```kotlin
data class ProfileUiState(
val user: User? = null,
val syncState: SyncState = SyncState.Idle,
val isLoading: Boolean = false,
)
```
The UI shows a subtle sync indicator — never block the user waiting for network.
---
## SyncManager Pattern
```kotlin
// :api
interface SyncManager {
fun requestSync(tag: SyncTag)
fun observeSyncState(tag: SyncTag): Flow<SyncState>
}
// :data — Android (WorkManager)
class AndroidSyncManager(private val workManager: WorkManager) : SyncManager {
override fun requestSync(tag: SyncTag) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.addTag(tag.id)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(tag.id, ExistingWorkPolicy.KEEP, request)
}
override fun observeSyncState(tag: SyncTag): Flow<SyncState> =
workManager.getWorkInfosByTagFlow(tag.id).map { infos ->
when (infos.firstOrNull()?.state) {
WorkInfo.State.RUNNING -> SyncState.Syncing
WorkInfo.State.SUCCEEDED -> SyncState.Synced
WorkInfo.State.FAILED -> SyncState.Error("Sync failed", retryable = true)
else -> SyncState.Idle
}
}
}
// SyncWorker
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result = try {
// Fetch from network and write to DB
val users = get<UserApiService>().getUsers()
get<AppDatabase>().userQueries.transaction {
users.forEach { get<AppDatabase>().userQueries.upsert(it.toEntity()) }
}
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
```
---
## Optimistic Updates with Rollback
```kotlin
override suspend fun updateName(id: UserId, name: String) {
val previous = db.userQueries.selectById(id.value).executeAsOneOrNull()
db.userQueries.updateName(name = name, id = id.value) // optimistic write
try {
api.updateUser(id, UpdateUserRequest(name = name)) // confirm with server
} catch (e: Exception) {
// Roll back on network failure
previous?.let { db.userQueries.updateName(name = it.name, id = id.value) }
throw e
}
}
```
---
## Conflict Resolution
When the server returns a version that differs from local, surface the conflict to the user:
```kotlin
suspend fun syncUser(id: UserId) {
val local = db.userQueries.selectById(id.value).executeAsOneOrNull() ?: return
val remote = api.getUser(id)
if (remote.updatedAt > local.updatedAt) {
db.userQueries.upsert(remote.toEntity()) // server wins on time
} else if (remote.updatedAt < local.updatedAt) {
// local is newer — push to server
api.updateUser(id, local.toRequest())
}
// Equal timestamps → no conflict, skip
}
```
For user-facing conflict (e.g. both sides edited the same field), emit `SyncState.Conflict`
and let the user decide — never silently overwrite user input.
---
## Koin Wiring
```kotlin
// androidMain platform module
single<SyncManager> { AndroidSyncManager(get()) }
single { WorkManager.getInstance(androidContext()) }
// iosMain platform module (BGTaskScheduler — see workmanager skill)
single<SyncManager> { IosSyncManager(get()) }
// commonMain
single<UserRepository> { UserRepositoryImpl(get(), get(), get()) }
```
---
## Testing
```kotlin
class FakeSyncManager : SyncManager {
val requested = mutableListOf<SyncTag>()
private val _state = MutableStateFlow<SyncState>(SyncState.Idle)
override fun requestSync(tag: SyncTag) { requested += tag }
override fun observeSyncState(tag: SyncTag): Flow<SyncState> = _state
fun emitSyncing() { _state.value = SyncState.Syncing }
fun emitSynced() { _state.value = SyncState.Synced }
fun emitError(msg: String) { _state.value = SyncState.Error(msg, retryable = true) }
}
@Test fun `updateName writes locally then requests sync`() = runTest {
val db = AppDatabase(testDriver())
db.userQueries.insert(id = "1", name = "Alice")
val sync = FakeSyncManager()
val repo = UserRepositoryImpl(db, FakeUserApiService(), sync)
repo.updateName(UserId("1"), "Bob")
assertEquals("Bob", db.userQueries.selectById("1").executeAsOne().name)
assertEquals(listOf(SyncTag.USER), sync.requested)
}
@Test fun `optimistic update rolls back on network error`() = runTest {
val db = AppDatabase(testDriver())
db.userQueries.insert(id = "1", name = "Alice")
val api = FakeUserApiService().apply { throwOnUpdate = true }
val repo = UserRepositoryImpl(db, api, FakeSyncManager())
runCatching { repo.updateName(UserId("1"), "Bob") }
assertEquals("Alice", db.userQueries.selectById("1").executeAsOne().name)
}
@Test fun `sync state flows to viewmodel ui state`() = runTest {
val sync = FakeSyncManager()
val vm = ProfileViewModel(FakeUserRepository(), sync)
vm.state.test {
assertEquals(SyncState.Idle, awaitItem().syncState)
sync.emitSyncing()
assertEquals(SyncState.Syncing, awaitItem().syncState)
sync.emitSynced()
assertEquals(SyncState.Synced, awaitItem().syncState)
cancelAndIgnoreRemainingEvents()
}
}
```
---
## Common Anti-Patterns
- **Reading from the network in the Repository's `observe` function** — the UI flow will
stall waiting for network. Always read from the local DB; trigger network sync separately.
- **Optimistic update without rollback** — if the network call fails and there's no rollback,
the local state diverges from the server permanently. Always store the previous value before
the optimistic write.
- **Blocking the UI on sync completion** — `requestSync` should fire-and-forget. Never `await`
on sync inside a composable or ViewModel's `init` block.
- **One SyncWorker for all data types** — a single slow sync blocks faster syncs. Use separate
`SyncTag` entries and enqueue them independently.
- **Conflict resolution with silent server-wins** — silently overwriting user edits breaks trust.
Surface `SyncState.Conflict` and let the user choose.
---
## Related Skills
- `kmp-sqldelight-setup` — local database; required for single-source-of-truth reads
- `kmp-repository-pattern` — the Repository interface that SyncManager plugs into
- `kmp-workmanager` — platform-specific background scheduling for Android/iOS sync
- `kmp-network-layer` — network calls the SyncWorker makes to the server
- `kmp-mvi` — SyncState surfaces through UiState; MVI pattern keeps it clean
---
## Output Style
When asked about offline-first or sync patterns, respond in this order:
1. recommendation (local-first read + background sync manager)
2. the SyncState sealed class and where it lives
3. repository snippet showing optimistic write + sync trigger
4. rollback pattern for the error case
5. testing approach (FakeSyncManager)
Lead with the data flow direction: DB → UI (read), UI → DB → network (write).
Never suggest reading from the network in the observe path.
---
## Changelog
| Date | Change |
|---|---|
| 2026-06-21 | Initial release. |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!