Offline-First Sync with Kotlin Multiplatform and SQLDelight

Mobile Development
Date:October 30, 2026
Topic:
Offline-First Sync with Kotlin Multiplatform and SQLDelight
⏱ 3 min read

Your field technician stands in a basement mechanical room. No signal. No Wi-Fi. The work order loads instantly anyway — schematics, asset history, checklists all there. They complete the job, tap sync, and walk away. That's not magic. That's offline-first architecture done right.

Why Offline-First Matters in 2026

Enterprise mobile apps face a hard reality: connectivity is unreliable. Field ops, logistics, healthcare, manufacturing — these environments have dead zones, metal interference, and security policies that block cloud access. Users expect apps to work regardless. Offline-first isn't a feature; it's table stakes.

Kotlin Multiplatform (KMP) changed the equation. Share business logic, data layer, and sync logic across Android and iOS without sacrificing native UI. SQLDelight gives you a type-safe, reactive database that compiles to SQLite on both platforms. Ktor handles networking. Compose Multiplatform (or native SwiftUI/Jetpack Compose) renders the UI. The stack is mature.

The Architecture: Local-First, Sync-Second

Core principle: the local database is the source of truth. The UI reads from SQLDelight flows. Mutations write locally first. Background workers reconcile with the server when connectivity returns. No loading spinners. No "pull to refresh" as a crutch.

kotlin
@Entity(tableName = "work_orders")
data class WorkOrder(
    @Column(name = "id", primaryKey = true) val id: String,
    @Column(name = "title") val title: String,
    @Column(name = "status") val status: SyncStatus,
    @Column(name = "updated_at") val updatedAt: Long,
    @Column(name = "server_version") val serverVersion: Long?
)

enum class SyncStatus { PENDING, SYNCED, CONFLICT }

SQLDelight: Shared Schema, Native Performance

Define your schema in `.sq` files. SQLDelight generates Kotlin APIs for both platforms. Queries return `Flow>` — reactive, lifecycle-aware, zero boilerplate. Migrations are versioned SQL files. No ORM magic, no runtime surprises.

sql
-- WorkOrder.sq
create table work_orders (
  id text not null primary key,
  title text not null,
  status text not null,
  updated_at integer not null,
  server_version integer
);

selectAll:
select * from work_orders order by updated_at desc;

selectPendingSync:
select * from work_orders where status = 'PENDING';

updateStatus:
update work_orders set status = ?, updated_at = ? where id = ?;
💡
TipUse `server_version` for optimistic locking. Increment on every server write. Reject local commits if versions diverge — forces conflict resolution.

Sync Engine: Deterministic, Resumable, Observable

Build a sync coordinator that runs in a `WorkManager` (Android) / `BGTaskScheduler` (iOS) worker. Steps: pull changes since last `server_version`, apply locally with conflict resolution, push local `PENDING` items, update `server_version`. Each step is idempotent. Failures retry with exponential backoff.

kotlin
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result = runCatching {
        val lastVersion = preferences.lastSyncedVersion
        val remoteChanges = api.fetchSince(lastVersion)
        database.transaction {
            remoteChanges.forEach { applyWithConflictResolution(it) }
            val pending = workOrderQueries.selectPendingSync().executeAsList()
            pending.forEach { api.push(it) }
            preferences.lastSyncedVersion = remoteChanges.maxVersion
        }
        Result.success()
    }.getOrElse { Result.retry() }
}
âš ī¸
WarningNever sync on the main thread. Use `withContext(Dispatchers.IO)` in shared code. Platform workers handle scheduling; your logic stays pure Kotlin.

Conflict Resolution: Last-Write-Wins Isn't Enough

Field apps generate concurrent edits. A technician updates a checklist while dispatch reassigns the work order. Last-write-wins loses data. Implement three strategies: server-wins for reference data, client-wins for user-generated content, and merge for structured fields (checklists, counters). Surface `CONFLICT` status to UI for manual resolution.

Data TypeStrategyExample
Asset metadataServer winsSerial number, location
Checklist answersMerge (union)Completed steps
Notes/photosClient winsTechnician observations
Status transitionsState machineOPEN → IN_PROGRESS → CLOSED

Testing Offline Behavior

Simulate network conditions in CI. Use `MockWebServer` with latency, errors, and partition scenarios. Test: cold start with stale data, sync during active edits, conflict resolution flows, battery-constrained background sync. SQLDelight's `TransactionManager` lets you test migrations in-memory.

â„šī¸
NoteAdd a debug overlay showing sync state, pending count, last successful sync timestamp. Invaluable for field QA.

âœĻ

Offline-first with KMP and SQLDelight isn't theoretical — it's shipping in production apps today. Start with the data model. Make local writes instant. Build sync as a background concern. Your users in the basement, the tunnel, the secure facility will never know the difference. They'll just know the app works.

Share𝕏 Twitterin LinkedInin Whatsapp