Building Production-Ready Offline-First Architecture with Kotlin Multiplatform (KMP)
Building a resilient offline-first mobile application requires treating local storage as the single source of truth while coordinating remote mutations through an atomic transactional queue. By combining Room KMP, platform-native Ktor client engines, and deterministic two-way synchronization pipelines, engineering teams can share over 90% of their database and data-sync logic across Android and iOS without sacrificing platform stability.
Why Offline-First Matters in Kotlin Multiplatform
Modern mobile users expect instant screen updates, zero UI blocking on spotty networks, and seamless data retention regardless of connectivity state. Treating the local SQLite database as the single source of truth guarantees instant reads and optimistic writes, while background synchronization handles reconciliation with remote backends.
With the release of official multiplatform support for Android Jetpack Room, developers can now write identical SQLite entity definitions, DAOs, and database queries across both Android and iOS targets. Paired with Ktor for cross-platform networking, this stack eliminates the operational friction of maintaining dual SQLite schemas in Kotlin and Swift.
Step 1: Configuring Gradle and KSP Dependencies for Room KMP
Configuring Room KMP requires applying the androidx.room Gradle plugin alongside Kotlin Symbol Processing (KSP) targeted at each individual platform binary. Because KSP runs per compilation target, you must explicitly bind the Room annotation processor to Android and every targeted iOS architecture in your build script.
The official Android Developers: Set up Room Database for KMP documentation defines the baseline setup. Below is a production-ready build.gradle.kts configuration utilizing standard version catalogs (libs.versions.toml):
// shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.ksp)
alias(libs.plugins.room)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
androidTarget()
// Explicitly configure each target architecture for iOS compilation
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "SharedDatabase"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
room {
schemaDirectory("$projectDir/schemas")
}
dependencies {
// Room compiler must be bound to KSP for every target compilation task
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
}Step 2: Setting Up Multiplatform Database Builders and SQLite Drivers
Instantiating a shared Room database requires standardizing the underlying SQLite driver while delegating file-path resolution to platform-specific builder factory functions. Room KMP uses BundledSQLiteDriver to ensure identical SQLite engine behavior on both platforms, avoiding unexpected differences between platform-bundled SQLite releases.
On iOS, SQLite database files must reside inside the sandbox's NSDocumentDirectory to ensure data persists across app restarts and updates.
1. Common Database Definition (commonMain)
// shared/src/commonMain/kotlin/database/AppDatabase.kt
package com.example.offline.database
import androidx.room.ConstructedBy
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.RoomDatabaseConstructor
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@Database(
entities = [SyncEntity::class, OutboxMutationEntity::class],
version = 1
)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun outboxDao(): OutboxDao
}
// Room KSP compiler generates the actual implementation per platform
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase>
fun createRoomDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}2. Android Database Builder (androidMain)
// shared/src/androidMain/kotlin/database/DatabaseBuilder.android.kt
package com.example.offline.database
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
fun getAndroidDatabaseBuilder(context: Context): RoomDatabase.Builder<AppDatabase> {
val appContext = context.applicationContext
val dbFile = appContext.getDatabasePath("app_offline.db")
return Room.databaseBuilder<AppDatabase>(
context = appContext,
name = dbFile.absolutePath
)
}3. iOS Database Builder (iosMain)
// shared/src/iosMain/kotlin/database/DatabaseBuilder.ios.kt
package com.example.offline.database
import androidx.room.Room
import androidx.room.RoomDatabase
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
fun getIosDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null
)?.path ?: throw IllegalStateException("Failed to resolve iOS NSDocumentDirectory path")
val dbFilePath = "$documentDirectory/app_offline.db"
return Room.databaseBuilder<AppDatabase>(
name = dbFilePath,
factory = { AppDatabaseConstructor.initialize() }
)
}Step 3: Configuring Platform-Optimized Ktor Network Engines
Production-ready multiplatform networking requires configuring Ktor engines tailored to the OS networking lifecycle. On Android, OkHttp delivers optimized connection pooling and transparent HTTP/2 multiplexing, while Darwin on iOS wraps NSURLSession with background connectivity waiting.
The official Ktor Multiplatform Client Documentation details how platform engines preserve OS energy constraints.
// shared/src/commonMain/kotlin/network/NetworkClient.kt
package com.example.offline.network
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
expect fun createPlatformHttpClient(): HttpClient
fun configureHttpClient(block: HttpClientConfig<*>.() -> Unit = {}): (HttpClientConfig<*>) -> Unit = { config ->
config.install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = false
encodeDefaults = true
})
}
config.install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
}
config.install(Logging) {
level = LogLevel.INFO
}
config.block()
}Implement the platform-specific factory functions:
// shared/src/androidMain/kotlin/network/NetworkClient.android.kt
package com.example.offline.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
actual fun createPlatformHttpClient(): HttpClient = HttpClient(OkHttp) {
configureHttpClient()(this)
engine {
config {
retryOnConnectionFailure(true)
}
}
}// shared/src/iosMain/kotlin/network/NetworkClient.ios.kt
package com.example.offline.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
actual fun createPlatformHttpClient(): HttpClient = HttpClient(Darwin) {
configureHttpClient()(this)
engine {
configureSession {
// Allows iOS to hold requests until a network connection becomes available
setWaitsForConnectivity(true)
}
}
}Step 4: Implementing the Atomic Transactional Outbox Pattern
The Transactional Outbox pattern guarantees that local domain records and pending synchronization actions are committed simultaneously inside a single SQLite transaction. This prevents dual-write failures where local data changes succeed but network mutations are dropped, or where mutations are queued without local persistence.
Following the principles in the Android Developers: Guide to Offline-First Architecture, entities and outbox mutations maintain deterministic state tracking:
// shared/src/commonMain/kotlin/database/Entities.kt
package com.example.offline.database
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.serialization.Serializable
enum class MutationType { CREATE, UPDATE, DELETE }
enum class SyncState { SYNCED, PENDING_SYNC, SYNC_ERROR }
@Entity(tableName = "records")
@Serializable
data class SyncEntity(
@PrimaryKey val id: String,
val title: String,
val content: String,
val version: Long,
val isDeleted: Boolean = false,
val syncState: SyncState = SyncState.SYNCED,
val localUpdatedAt: Long
)
@Entity(tableName = "mutation_outbox")
data class OutboxMutationEntity(
@PrimaryKey val mutationId: String, // Unique client-generated UUID
val sequenceNumber: Long, // Strictly incrementing local sequence
val entityId: String, // Target entity ID
val mutationType: MutationType, // CREATE, UPDATE, DELETE
val payloadJson: String, // Serialized payload
val idempotencyKey: String, // Token sent via HTTP header
val createdAt: Long,
val retryCount: Int = 0
)
@Dao
interface OutboxDao {
@Upsert
suspend fun upsertEntity(entity: SyncEntity)
@Query("SELECT * FROM records WHERE id = :id")
suspend fun getEntityById(id: String): SyncEntity?
@Query("SELECT * FROM records WHERE isDeleted = 0 ORDER BY localUpdatedAt DESC")
suspend fun getAllActiveRecords(): List<SyncEntity>
@Insert
suspend fun insertMutation(mutation: OutboxMutationEntity)
@Query("SELECT * FROM mutation_outbox ORDER BY sequenceNumber ASC")
suspend fun getPendingMutations(): List<OutboxMutationEntity>
@Query("DELETE FROM mutation_outbox WHERE mutationId = :mutationId")
suspend fun deleteMutation(mutationId: String)
@Transaction
suspend fun applyOptimisticMutation(entity: SyncEntity, mutation: OutboxMutationEntity) {
upsertEntity(entity)
insertMutation(mutation)
}
}Step 5: Building the Deterministic Two-Way Sync Pipeline
A production synchronization loop operates in two ordered, deterministic phases: draining pending outbox mutations upstream, followed by pulling remote delta updates via cursor checkpoints. Using HTTP idempotency headers (X-Idempotency-Key) prevents duplicate processing if network connections drop mid-flight.
If incoming remote updates overlap with locally modified records, a deterministic Last-Write-Wins (LWW) rule compares the remote entity version and timestamps to decide whether to update local state or retain the pending outbox mutation.
// shared/src/commonMain/kotlin/sync/DeterministicSyncEngine.kt
package com.example.offline.sync
import com.example.offline.database.AppDatabase
import com.example.offline.database.SyncEntity
import com.example.offline.database.SyncState
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
@Serializable
data class SyncPullResponse(
val records: List<SyncEntity>,
val nextCursor: String?,
val hasMore: Boolean
)
class DeterministicSyncEngine(
private val database: AppDatabase,
private val httpClient: HttpClient,
private val apiBaseUrl: String
) {
private val syncMutex = Mutex()
suspend fun executeSync(currentCursor: String?): Result<String?> = syncMutex.withLock {
runCatching {
val outboxDao = database.outboxDao()
val pendingMutations = outboxDao.getPendingMutations()
// ----------------------------------------------------
// 1. Upstream Drain (Push Phase)
// ----------------------------------------------------
for (mutation in pendingMutations) {
val response = httpClient.post("$apiBaseUrl/sync/mutation") {
contentType(ContentType.Application.Json)
headers {
append("X-Idempotency-Key", mutation.idempotencyKey)
}
setBody(mutation.payloadJson)
}
if (response.status.isSuccess()) {
outboxDao.deleteMutation(mutation.mutationId)
} else if (response.status.value in 400..499) {
// Non-retryable client error / business conflict: discard to unblock queue
outboxDao.deleteMutation(mutation.mutationId)
} else {
// 5xx or network transport error: abort and preserve queue order
throw IllegalStateException("Server error during upstream push: ${response.status}")
}
}
// ----------------------------------------------------
// 2. Downstream Delta Pull (Pull Phase)
// ----------------------------------------------------
val pullResponse = httpClient.get("$apiBaseUrl/sync/delta") {
parameter("cursor", currentCursor)
}.body<SyncPullResponse>()
val remainingPendingMutations = outboxDao.getPendingMutations().map { it.entityId }.toSet()
for (remoteRecord in pullResponse.records) {
val localRecord = outboxDao.getEntityById(remoteRecord.id)
if (localRecord == null || !remainingPendingMutations.contains(remoteRecord.id)) {
// No local pending mutations exist for this entity: safe to overwrite
outboxDao.upsertEntity(remoteRecord.copy(syncState = SyncState.SYNCED))
} else {
// Conflict Resolution: LWW with timestamp check
if (remoteRecord.version > localRecord.version && remoteRecord.localUpdatedAt > localRecord.localUpdatedAt) {
outboxDao.upsertEntity(remoteRecord.copy(syncState = SyncState.SYNCED))
}
// Otherwise, retain local pending optimistic state
}
}
pullResponse.nextCursor
}
}
}Step 6: Scheduling Background Sync on Android and iOS
Running background synchronization ensures local caches remain fresh without requiring active user interaction. Android relies on WorkManager with network constraints and exponential retry policies, while iOS leverages BGTaskScheduler registered under UIBackgroundModes.
Review the platform documentation at Android Developers: WorkManager Background Processing and Apple Developer Documentation: BGTaskScheduler when configuring background permissions.
Android WorkManager Implementation (androidMain)
// androidApp/src/main/kotlin/com/example/offline/worker/SyncWorker.kt
package com.example.offline.worker
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters
import com.example.offline.sync.DeterministicSyncEngine
import java.util.concurrent.TimeUnit
class SyncWorker(
appContext: Context,
workerParams: WorkerParameters,
private val syncEngine: DeterministicSyncEngine
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return when (val syncResult = syncEngine.executeSync(currentCursor = null)) {
Result.success(syncResult.getOrNull()) -> Result.success()
else -> Result.retry()
}
}
companion object {
fun buildPeriodicWorkRequest() = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 15,
repeatIntervalTimeUnit = TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).build()
}
}iOS Background Task Scheduling (iosApp)
Register the background identifier in your iOS target's Info.plist under BGTaskSchedulerPermittedIdentifiers, and handle scheduling via Swift:
// iosApp/iOSApp.swift
import SwiftUI
import BackgroundTasks
import SharedDatabase
@main
struct iOSApp: App {
private let backgroundSyncTaskId = "com.example.offline.sync"
init() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundSyncTaskId, using: nil) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
private func handleAppRefresh(task: BGAppRefreshTask) {
scheduleNextAppRefresh()
let syncEngine = DependencyGraph.shared.syncEngine
Task {
let result = await syncEngine.executeSync(currentCursor: nil)
task.setTaskCompleted(success: result.isSuccess)
}
task.expirationHandler = {
task.setTaskCompleted(success: false)
}
}
private func scheduleNextAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: backgroundSyncTaskId)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try? BGTaskScheduler.shared.submit(request)
}
}Step 7: Common Pitfalls and Production Best Practices
Deploying offline-first multiplatform architectures requires avoiding synchronization deadlocks and unhandled edge cases across compilation targets. The table below lists common production failure modes and their pragmatic fixes:
| Failure Mode | Root Cause | Practical Resolution |
|---|---|---|
| KSP Missing on iOS Simulator | Room compiler was only attached to kspAndroid or kspIosArm64. |
Add explicit add("kspIosSimulatorArm64", libs.androidx.room.compiler) tasks for every active architecture. |
| Duplicate Mutated Records | Client resends mutations over unstable cellular connections without idempotency keys. | Enforce unique client UUIDs in the X-Idempotency-Key header and maintain an idempotency index on the server. |
| Stuck Outbox Queue | An unhandled 4xx error (e.g., entity validation failure) keeps failing the queue on retry. | Catch 4xx status codes, drop the offending mutation from the outbox, and notify the domain layer. |
| iOS Sandbox Path Failure | Hardcoding /tmp or relative database paths causes permission crashes or data eviction. |
Always resolve paths dynamically using NSFileManager.defaultManager.URLForDirectory(NSDocumentDirectory, ...) in iosMain. |
| Sync Race Conditions | Parallel UI actions trigger multiple sync loops simultaneously. | Protect executeSync() using a shared kotlinx.coroutines.sync.Mutex instance. |
Frequently Asked Questions
How do I handle Room KMP database migrations across Android and iOS?
Room KMP supports standard Migration(startVersion, endVersion) declarations passed directly into the platform database builder in commonMain. Export database schemas to a shared directory using room { schemaDirectory("$projectDir/schemas") } in Gradle, allowing KSP to validate schema modifications at compile time across both platforms.
What happens if an outbox mutation fails due to a 4xx client error?
A 4xx HTTP status code indicates a non-retryable validation or business rule conflict (e.g., updating a deleted record). The sync engine must catch 4xx responses, remove the invalid mutation from the outbox table to unblock the remaining queue, and optionally persist a local SyncState.SYNC_ERROR flag to alert the UI layer.
Why should I use BundledSQLiteDriver instead of native platform SQLite drivers?
Using BundledSQLiteDriver bundles the exact same SQLite engine version across both Android and iOS targets. Relying on platform-native SQLite drivers can expose behavioral discrepancies, different default collation rules, or missing SQLite features between varying Android API levels and iOS versions.
About the Author
Furkan Çetinkaya is a Mobile-focused Software Developer specializing in React Native, native bridge integrations (Kotlin & Swift), and supporting backend services. Experienced in maintaining high-impact mobile applications and developer SDKs.