Offline-First Kotlin Multiplatform with Room and Ktor
A pragmatic architecture for KMP apps where the local Room database is the source of truth and Ktor is a background sync engine — not a request/response layer bolted onto the UI.
In a Kotlin Multiplatform app, the network is a detail. The local database is the application. Once you accept that, the architecture collapses into something clean: Room is the single source of truth that the UI observes, and Ktor is a background sync engine that reconciles that local state with a remote server. The UI never awaits a network call, never shows a spinner tied to HTTP, and never has to reason about connectivity.
This article lays out that architecture concretely, using Room's KMP support (2.7+) and Ktor 3.x, with shared code in commonMain.
Why Room is the source of truth, not a cache
A cache is something you invalidate. A source of truth is something you write to. The distinction matters because it dictates the direction of data flow. In an offline-first app:
- User actions write to Room first, synchronously, and return immediately.
- The UI subscribes to Room via
Flowand re-renders whenever rows change. - A separate sync process pushes local changes upstream and pulls remote changes down, upserting them into Room.
Ktor is invisible to the UI. If the server is unreachable, nothing in the user-facing flow breaks — the write already succeeded locally, and the sync engine will retry.
Setting up Room in commonMain
Room's KMP artifact (androidx.room:room-runtime 2.7.0+) lets you declare entities, DAOs, and the database itself in shared code. You still need a small platform-specific factory to hand it a file path.
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val body: String,
val updatedAt: Long,
val syncStatus: SyncStatus,
val remoteVersion: Long?
)
enum class SyncStatus { SYNCED, PENDING_UPLOAD, PENDING_DELETE }
@Dao
interface NoteDao {
@Query("SELECT * FROM notes WHERE syncStatus != 'PENDING_DELETE' ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<NoteEntity>>
@Query("SELECT * FROM notes WHERE syncStatus != 'SYNCED'")
suspend fun pendingChanges(): List<NoteEntity>
@Upsert
suspend fun upsert(note: NoteEntity)
}The syncStatus column is the entire mechanism. It's how the sync engine knows what to push. Every mutation the user makes writes PENDING_UPLOAD. Every successful server response writes SYNCED. Deletes are soft (PENDING_DELETE) until confirmed, then hard-deleted.
The Ktor client as a sync engine, not an API layer
The mistake most teams make is exposing Ktor calls through the repository. The repository should only ever return Flow from Room. Ktor lives behind a SyncEngine that operates on its own coroutine scope.
class SyncEngine(
private val dao: NoteDao,
private val client: HttpClient,
private val scope: CoroutineScope
) {
fun start() {
scope.launch {
while (isActive) {
runCatching { syncOnce() }
delay(30_000)
}
}
}
private suspend fun syncOnce() {
// Push local changes
dao.pendingChanges().forEach { local ->
when (local.syncStatus) {
SyncStatus.PENDING_UPLOAD -> push(local)
SyncStatus.PENDING_DELETE -> deleteRemote(local)
else -> Unit
}
}
// Pull remote changes since last known version
val since = dao.maxRemoteVersion() ?: 0
val remote: List<NoteDto> = client.get("/notes") {
parameter("since", since)
}.body()
remote.forEach { dao.upsert(it.toEntity(SyncStatus.SYNCED)) }
}
}Note what's absent: no suspend fun getNotes() on the repository, no loading states leaking into view models, no try/catch around network calls in the UI. The sync loop is a background process. It fails, it retries, it doesn't block anyone.
The repository is a thin read/write facade
class NoteRepository(private val dao: NoteDao, private val clock: Clock) {
fun observeNotes(): Flow<List<Note>> =
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
suspend fun save(id: String, body: String) {
dao.upsert(
NoteEntity(
id = id,
body = body,
updatedAt = clock.now().toEpochMilliseconds(),
syncStatus = SyncStatus.PENDING_UPLOAD,
remoteVersion = null
)
)
}
}The repository never touches Ktor. It never returns Result or Loading. Writes are fire-and-forget from the caller's perspective — they always succeed locally, and eventual consistency with the server is the sync engine's problem.
Conflict resolution: pick a strategy and enforce it in one place
The hard part of offline-first isn't the plumbing — it's what happens when the same row was edited on two devices. You need one, and only one, conflict rule. In practice, three strategies cover most apps:
- Last-write-wins by timestamp. Simple, correct for personal notes, disastrous for collaborative documents.
- Server-wins on push conflict. The server rejects stale versions; the client re-fetches and re-applies the local diff.
- Field-level merge with a version vector. The most robust, but requires cooperation from the backend.
Whichever you pick, the resolution logic belongs inside SyncEngine, not scattered across DAOs or view models. When a push returns HTTP 409, the engine pulls the current server state, merges according to policy, and writes the result back to Room. The UI sees the merged row appear via the same Flow it was already observing. No new code path.
Platform glue: keep it small
You need two expect/actual pieces: a Room database builder that knows where to put the file, and a Ktor engine choice per platform.
// commonMain
expect fun databaseBuilder(): RoomDatabase.Builder<AppDatabase>
// androidMain
actual fun databaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val ctx = ApplicationProvider.get()
val file = ctx.getDatabasePath("app.db")
return Room.databaseBuilder<AppDatabase>(ctx, file.absolutePath)
}
// iosMain
actual fun databaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dir = NSFileManager.defaultManager.URLForDirectory(
NSDocumentDirectory, NSUserDomainMask, null, true, null
)
val path = requireNotNull(dir).path + "/app.db"
return Room.databaseBuilder<AppDatabase>(name = path, factory = { AppDatabase::class.instantiateImpl() })
}For Ktor, use OkHttp on Android and Darwin on iOS. Both integrate with the platform's system-level connectivity behaviour, which matters when the sync engine wakes up on a metered network.
What to build first
Resist building the sync engine on day one. The correct order is:
- Room schema with
syncStatuscolumn baked in from the start. - Repository returning
Flow, wired into the UI. Prove the app works entirely offline with seeded data. - A one-shot sync function you invoke manually. Prove push and pull work in isolation.
- The polling/backoff loop, WorkManager on Android,
BGTaskScheduleron iOS. - Conflict resolution, only once you have real conflicts to reason about.
Ship the offline path first. If the app is unusable without a network, you have built a networked app with a database attached — which is exactly what this architecture exists to avoid.
Oussama Aniba Newsletter
Join the newsletter to receive the latest updates in your inbox.