Back to blog
Kotlin MultiplatformKMPOffline FirstSQLDelightAndroidiOSBackground TasksCoroutinesOpen Source

Offline-First Background Sync in Kotlin Multiplatform: SQLite Queues, Network Probing, and Directed Acyclic Graphs

N
Neural Heads Team
Engineering
September 4, 2026
Offline-First Background Sync in Kotlin Multiplatform: SQLite Queues, Network Probing, and Directed Acyclic Graphs

Offline-First Background Sync in Kotlin Multiplatform: SQLite Queues, Network Probing, and Directed Acyclic Graphs

In Part 1 of this series, we examined the core dispatch engine of KMPWorker—our open-source Kotlin Multiplatform library—and how it reconciles the deep philosophical divide between Android’s WorkManager and Apple’s BGTaskScheduler.

Bridging two background scheduling APIs is only half the battle. In production mobile engineering, scheduled background jobs rarely execute under pristine conditions. Users walk into subway tunnels, switch to airplane mode, or drop off flaky cellular towers right as a critical data sync is enqueued.

If your background abstraction relies on in-memory coroutine scopes or volatile queues, those tasks disappear the moment the process terminates or memory pressure triggers an OS kill. Conversely, if you blindly dump tasks into the underlying OS schedulers without local persistence, you lose deduplication guarantees, local priority ordering, and fine-grained control over execution graphs.

To make background execution resilient on consumer devices, we designed an offline-first sync engine directly into KMPWorker. This article walks through the architectural decisions behind OfflineQueue, compile-time typesafe persistence with SQLDelight, platform-specific network monitoring realities on iOS and Android, and our experimental Directed Acyclic Graph (DAG) execution engine for multi-step background pipelines.


The Durability Guarantee: Why In-Memory Queues Fail

Every mobile engineer has written code that looks like this:

fun scheduleSync(mutation: SyncMutation) {
    scope.launch {
        if (isNetworkAvailable()) {
            api.sync(mutation)
        } else {
            retryChannel.send(mutation)
        }
    }
}

This pattern has fatal flaws:

  1. Volatile state: If the user swipes the app from the recents screen, or the system reclaims the process to free RAM for the camera, the coroutine scope is cancelled and unconsumed items in retryChannel vanish.

  2. Double execution: If network connectivity oscillates during a request, an in-memory retry loop easily dispatches duplicate requests unless the database tracks request status transitions atomically.

  3. No priority inversion prevention: Important payloads (like user-initiated checkouts or immediate chat sends) get stuck behind voluminous telemetry uploads.

For background work to be genuinely reliable, the system must adhere to a strict rule: No task exists solely in memory. Every task request must be committed to durable local storage before execution is attempted, or before it is queued for deferred replay.


Persistence Architecture with SQLDelight

Rather than introducing heavyweight ORMs or maintaining separate platform database layers (like Room on Android and SwiftData or CoreData on iOS), we standardized on SQLDelight.

SQLDelight generates typesafe Kotlin APIs from pure SQL queries at compile time. By placing SQL schema definitions in commonMain, both Android and iOS share identical query logic, table constraints, and serialization rules without runtime reflection.

The Tasks Schema

The core persistence contract lives in tasks.sq:

-- KMPWorker task persistence schema
-- Database: KmpWorkerDatabase

CREATE TABLE tasks (
    id          TEXT    NOT NULL PRIMARY KEY,
    type        TEXT    NOT NULL,           -- "OneTime" | "Periodic" | "ExactTime" | "Windowed"
    status      TEXT    NOT NULL,           -- "PENDING" | "RUNNING" | "SUCCESS" | "FAILED"
    retry_count INTEGER NOT NULL DEFAULT 0,
    payload     TEXT,                       -- Optional serialized task payload
    created_at  INTEGER NOT NULL,           -- Unix epoch millis
    priority    TEXT    NOT NULL DEFAULT 'NORMAL', -- "HIGH" | "NORMAL" | "LOW"
    timeout_ms  INTEGER                     -- Optional timeout in milliseconds
);

insertTask:
INSERT OR IGNORE INTO tasks (id, type, status, retry_count, payload, created_at, priority, timeout_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);

updateStatus:
UPDATE tasks
SET status = ?, retry_count = ?
WHERE id = ?;

deleteTask:
DELETE FROM tasks WHERE id = ?;

getPending:
SELECT * FROM tasks
WHERE status IN ('PENDING', 'RUNNING')
ORDER BY 
    CASE priority
        WHEN 'HIGH' THEN 1
        WHEN 'NORMAL' THEN 2
        WHEN 'LOW' THEN 3
        ELSE 4
    END,
    created_at ASC;

getById:
SELECT * FROM tasks WHERE id = ?;

A few key decisions in this schema:

  • Deduplication via Primary Key: insertTask uses INSERT OR IGNORE keyed by id. If a business flow triggers multiple sync requests with the same task identifier while offline, the repository rejects duplicates without throwing exceptions or corrupting existing records.

  • Priority-Aware Retrieval: The getPending query does not use a simple chronological scan. It uses a CASE statement to sort by priority bucket (HIGHNORMALLOW), and applies chronological ordering (created_at ASC) only within each priority tier. When connectivity returns, high-priority transactions jump ahead of routine maintenance.

Cross-Platform Drivers

Initializing SQLite requires platform-native drivers. In KmpWorkerDatabaseFactory, we wire the drivers per target:

On Android (androidMain):

object KmpWorkerDatabaseFactory {
    fun create(context: Context, name: String = "kmpworker.db"): KmpWorkerDatabase {
        val driver = AndroidSqliteDriver(
            schema = KmpWorkerDatabase.Schema,
            context = context,
            name = name
        )
        return KmpWorkerDatabase(driver)
    }
}

On iOS (iosMain):

object KmpWorkerDatabaseFactory {
    fun create(name: String = "kmpworker.db"): KmpWorkerDatabase {
        val driver = NativeSqliteDriver(
            schema = KmpWorkerDatabase.Schema,
            name = name
        )
        return KmpWorkerDatabase(driver)
    }
}

Thread Safety and Dispatchers

In SqlDelightTaskRepository, all database queries are explicitly wrapped in withContext(Dispatchers.IO):

class SqlDelightTaskRepository(
    private val database: KmpWorkerDatabase
) : TaskRepository {

    private val queries get() = database.tasksQueries

    override suspend fun insert(task: TaskRequest): Unit = withContext(Dispatchers.IO) {
        queries.insertTask(
            id         = task.id,
            type       = task.type.toDbString(),
            status     = "PENDING",
            retry_count = 0L,
            payload    = task.payload,
            created_at = currentEpochMillis(),
            priority   = task.priority.name,
            timeout_ms = task.timeout?.inWholeMilliseconds
        )
    }

    override suspend fun getPending(): List<TaskRequest> = withContext(Dispatchers.IO) {
        queries.getPending(::mapRow).executeAsList().map { it.toTaskRequest() }
    }
    
    // ...
}

Because Kotlin/Native has strict memory and thread concurrency characteristics, binding all SQLite I/O to Dispatchers.IO guarantees non-blocking execution regardless of whether a task is enqueued from the UI thread or triggered from a background worker thread.


Reactive Network Monitoring: The Divergence Between Android and iOS

An offline queue needs to know when connectivity returns so it can trigger replays automatically. The common interface is clean:

interface NetworkMonitor {
    val isOnline: StateFlow<Boolean>
    fun isCurrentlyOnline(): Boolean
    fun start()
    fun stop()
}

Under the hood, however, Android and iOS report network state in fundamentally different ways.

Android: System Callbacks via ConnectivityManager

Android provides event-driven network monitoring via ConnectivityManager.NetworkCallback. When registering a NetworkRequest requiring NET_CAPABILITY_INTERNET, the Android OS pushes connectivity transitions directly into the app:

class AndroidNetworkMonitor(context: Context) : NetworkMonitor {
    private val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    private val _isOnline = MutableStateFlow(isCurrentlyOnline())
    override val isOnline: StateFlow<Boolean> = _isOnline

    private val networkCallback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
            _isOnline.value = true
        }

        override fun onLost(network: Network) {
            _isOnline.value = isCurrentlyOnline()
        }

        override fun onCapabilitiesChanged(
            network: Network,
            networkCapabilities: NetworkCapabilities
        ) {
            _isOnline.value = networkCapabilities.hasCapability(
                NetworkCapabilities.NET_CAPABILITY_INTERNET
            )
        }
    }

    override fun start() {
        val request = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .build()
        connectivityManager.registerNetworkCallback(request, networkCallback)
    }
}

This is lightweight, event-driven, and consumes zero battery when idle.

iOS: Documentation vs Engineering Reality

In Apple’s documentation, the standard advice for monitoring network paths is NWPathMonitor from Network.framework.

In Kotlin Multiplatform, however, NWPathMonitor presents practical integration issues. Platform C-interop bindings for Network.framework vary across Kotlin/Native toolchain versions, and callback handlers from Grand Central Dispatch (GCD) blocks can introduce complex threading and freezing constraints across Kotlin native runtimes. Furthermore, NWPathMonitor frequently reports that an interface is “satisfied” when a device connects to a public Wi-Fi hotspot, even when there is no actual internet egress due to a captive portal.

In IOSNetworkMonitor, we implemented an active probe model:

class IOSNetworkMonitor(
    private val checkIntervalMs: Long = 10_000L
) : NetworkMonitor {

    private val _isOnline = MutableStateFlow(true)
    override val isOnline: StateFlow<Boolean> = _isOnline

    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
    private var running = false

    override fun start() {
        if (running) return
        running = true
        scope.launch {
            while (isActive && running) {
                checkConnectivity()
                delay(checkIntervalMs)
            }
        }
    }

    private fun checkConnectivity() {
        try {
            val url = NSURL.URLWithString("https://captive.apple.com/hotspot-detect.html")
                ?: return
            val request = NSURLRequest.requestWithURL(url)
            var online = false
            val semaphore = platform.darwin.dispatch_semaphore_create(0)
            
            NSURLSession.sharedSession.dataTaskWithRequest(request) { _, response, error ->
                online = error == null && response != null
                platform.darwin.dispatch_semaphore_signal(semaphore)
            }.resume()
            
            platform.darwin.dispatch_semaphore_wait(semaphore, platform.darwin.DISPATCH_TIME_FOREVER)
            _isOnline.value = online
        } catch (_: Exception) {
            _isOnline.value = false
        }
    }
}

By hitting Apple’s designated captive portal verification endpoint (https://captive.apple.com/hotspot-detect.html) using NSURLSession and synchronizing the completion handler via a Darwin dispatch semaphore, the iOS monitor confirms true end-to-end IP egress. The trade-off is that it uses a 10-second polling interval while active rather than an asynchronous kernel interrupt.


How OfflineQueue Coordinates Execution

The OfflineQueue ties the worker, the persistence layer, and the network monitor into a cohesive state machine:

class OfflineQueue(
    private val worker: KmpWorker,
    private val repository: TaskRepository,
    private val networkMonitor: NetworkMonitor
) {
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob() + exceptionHandler)
    private var started = false

    fun start() {
        if (started) return
        started = true

        networkMonitor.isOnline
            .onEach { online ->
                if (online) {
                    KmpWorkerLogger.i("OfflineQueue: network restored, replaying pending tasks")
                    safeReplay()
                }
            }
            .launchIn(scope)
    }

    suspend fun enqueue(request: TaskRequest) {
        if (networkMonitor.isCurrentlyOnline()) {
            KmpWorkerLogger.d("OfflineQueue: online — dispatching '${request.id}' immediately")
            executeNow(request)
        } else {
            val alreadyPending = repository.getById(request.id) != null
            if (alreadyPending) {
                KmpWorkerLogger.d("OfflineQueue: '${request.id}' already pending, skipping duplicate")
                return
            }
            KmpWorkerLogger.d("OfflineQueue: offline — persisting '${request.id}' for later replay")
            repository.insert(request)
        }
    }

    suspend fun replay() {
        val pending = repository.getPending()
        if (pending.isEmpty()) return

        KmpWorkerLogger.i("OfflineQueue: replaying ${pending.size} pending task(s)")
        pending.forEach { request ->
            try {
                executeNow(request)
                repository.delete(request.id)
            } catch (e: Exception) {
                KmpWorkerLogger.e("OfflineQueue: failed to replay '${request.id}'", e)
                // Remains in SQLite to be retried on next connectivity restore
            }
        }
    }
}

Execution Lifecycle

  1. When Online: Calling offlineQueue.enqueue(task) bypasses disk writes and forwards directly to KmpWorker.enqueue(task). On Android, this schedules via WorkManager; on iOS, via BGTaskScheduler.

  2. When Offline: The queue inspects SQLite. If the task ID is already present as PENDING or RUNNING, it drops the duplicate. Otherwise, it writes the request to the tasks table with status PENDING.

  3. When Network Resumes: The networkMonitor.isOnline StateFlow emits true. safeReplay() fetches all pending tasks in priority order and dispatches them through executeNow(). Only when dispatch succeeds is the record deleted from SQLite. If dispatch fails, the record stays in the database for the next replay cycle.

  4. App Restart Recovery: Because tasks are committed to disk, pending items survive process termination. Calling offlineQueue.start() or offlineQueue.replay() on app launch immediately drains any tasks that were queued prior to process death.


Beyond Linear Chains: Directed Acyclic Graphs (DAGs)

In Article 1, we discussed sequential task chaining using TaskChain:

Step A ───▶ Step B ───▶ Step C

Sequential pipelines work well for linear processes (such as authentication followed by profile download). However, complex real-world synchronization requires concurrency with fan-out and fan-in topologies:

                  ┌──▶ Process Deltas ──┐
Fetch Remote ────┤                     ├──▶ Upload Snapshot
                  └──▶ Validate Cache  ──┘

Here, Process Deltas and Validate Cache can run in parallel, but Upload Snapshot must strictly wait until both upstream tasks complete successfully.

To solve this without nesting callbacks or chaining ad-hoc coroutines, we introduced the TaskGraph engine.

The Graph DSL

In core, we created a declarative DSL for defining nodes and dependency edges:

@OptIn(ExperimentalKmpWorkerApi::class)
kmpWorker.graph("workspace-sync") {
    val fetch = task("fetch-remote")
    val process = task("process-deltas")
    val validate = task("validate-cache")
    val upload = task("upload-snapshot") {
        constraints = Constraints(requiresInternet = true)
        priority = TaskPriority.HIGH
    }

    fetch then process     // process depends on fetch
    fetch then validate    // validate runs concurrently with process
    process then upload    // upload waits for both process and validate
    validate then upload
}

The infix then operator registers a directed edge:

inner class TaskGraphNode(val id: String) {
    infix fun then(other: TaskGraphNode): TaskGraphNode {
        edges.add(TaskGraph.Edge(from = this.id, to = other.id))
        return other
    }
}

The Execution Engine

The TaskGraphExecutor runs the graph by resolving dependencies dynamically:

@ExperimentalKmpWorkerApi
class TaskGraphExecutor(
    private val worker: KmpWorker,
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
) {
    suspend fun execute(graph: TaskGraph) {
        TaskMonitor.emit(graph.id, TaskState.Running())

        val completed = mutableSetOf<String>()
        val failed = mutableSetOf<String>()
        val remaining = graph.nodes.map { it.id }.toMutableSet()

        while (remaining.isNotEmpty() && failed.isEmpty()) {
            // Find nodes whose dependencies are all completed
            val ready = remaining.filter { nodeId ->
                graph.dependenciesOf(nodeId).all { it in completed }
            }

            // Cycle detection: remaining nodes exist, but none can proceed
            if (ready.isEmpty() && remaining.isNotEmpty()) {
                TaskMonitor.emit(graph.id, TaskState.Failed(
                    throwable = Exception("Cycle detected in graph '${graph.id}'"),
                    willRetry = false
                ))
                return
            }

            // Execute all unblocked nodes concurrently
            val results = coroutineScope {
                ready.map { nodeId ->
                    async {
                        val request = graph.nodes.first { it.id == nodeId }
                        try {
                            worker.enqueue(request)
                            val state = worker.observe(nodeId).first { it.isTerminal }
                            nodeId to (state is TaskState.Success)
                        } catch (e: Exception) {
                            nodeId to false
                        }
                    }
                }.map { it.await() }
            }

            // Process results deterministically on the calling coroutine
            for ((nodeId, success) in results) {
                if (success) completed.add(nodeId) else failed.add(nodeId)
            }

            remaining.removeAll(completed)
            remaining.removeAll(failed)
        }

        if (failed.isNotEmpty()) {
            TaskMonitor.emit(graph.id, TaskState.Failed(
                throwable = Exception("Graph '${graph.id}' failed at nodes: ${failed.joinToString()}"),
                willRetry = false
            ))
        } else {
            TaskMonitor.emit(graph.id, TaskState.Success)
        }
    }
}

Key Execution Properties

  1. Dynamic Fan-Out: Any node whose upstream dependencies are met is scheduled immediately in parallel using async { ... } within a structured coroutineScope.

  2. Cycle Detection: If remaining tasks exist but ready is empty, the graph contains a circular dependency (e.g. A then B and B then A). The executor detects this condition and emits a terminal TaskState.Failed instead of hanging the thread indefinitely.

  3. Fail-Fast Semantics: If any task fails and exhausts its configured retries, the entire graph aborts, preventing dependent nodes from executing with invalid state.


Engineering Trade-Offs: Chains vs. Graphs

It is important to understand the practical trade-offs between TaskChain and TaskGraph in their current implementations:

FeatureSequential TaskChainDirected Acyclic Graph (TaskGraph)Execution TopologyPurely sequential (A→B→CABC)Branching, parallel fan-out, fan-in joinsPersistence StatusDurable: Step progress written to SQLite (chain_progress table) before each stepIn-Memory: State evaluated in coroutine memoryCrash SafetySurvives app termination; resumes at last committed step via restorePendingChains()Aborts if the OS terminates the process mid-graphAPI StabilityProduction Ready (kmpworker-core)Marked @ExperimentalKmpWorkerApi

In TaskChainExecutor, every single step transition is committed to SQLite before the subsequent step is enqueued:

private suspend fun onStepSuccess(chain: TaskChain, completedStep: Int) {
    val nextStep = completedStep + 1
    if (nextStep < chain.totalSteps) {
        // Persist progress BEFORE enqueueing next step (critical for crash safety)
        chainRepository.updateStep(chain.id, nextStep, "RUNNING")
        enqueueStep(chain, nextStep)
    } else {
        chainRepository.updateStep(chain.id, completedStep, "COMPLETED")
        TaskMonitor.emit(chain.id, TaskState.Success)
    }
}

If iOS terminates the app due to its 30-second background execution limit while step 2 is running, the database retains current_step = 1. On the next cold launch, restorePendingChains() resumes execution directly from step 2, without repeating completed work.

TaskGraph does not yet possess this level of granular step persistence across arbitrary branching topologies. If the process dies while parallel branches are executing, the graph executor does not attempt to reconstruct the active join boundaries on restart. That is why TaskGraph remains gated behind @ExperimentalKmpWorkerApi. For mission-critical background jobs that must survive process death, TaskChain combined with OfflineQueue remains the recommended approach.


Practical Lessons for Mobile Teams

  1. Treat Network State as a Hint, Not a Guarantee: An operating system telling you that Wi-Fi is active does not mean you have an open socket to your API gateway. Always structure your network payloads with idempotency keys so that if a socket drops mid-flight and OfflineQueue replays the payload later, your backend handles the duplicate safely.

  2. Keep Persistent Payloads Small: In SqlDelightTaskRepository, task payloads are stored as TEXT. Avoid shoving multi-megabyte binary blobs or images directly into the SQLite column. Instead, store file URIs or local cache identifiers in the task payload, and let your worker read the actual data from disk during execution.

  3. Decouple Task Scheduling from App Lifecycle: By encapsulating replay logic inside OfflineQueue.start(), UI components (Activities on Android, ViewControllers on iOS) do not need to register manual network listeners or trigger sync loops. The queue operates autonomously in the background scope.


What’s Next

Having a resilient offline queue and DAG execution engine solves the background processing problem, but it introduces an observability challenge: how do you know what tasks are currently queued, running, retrying, or failing across your user base?

In the final article of this series, we will look at real-time telemetry and debugging in Kotlin Multiplatform—covering the TaskMonitor event bus, persistent telemetry stores, and how we built the visual kmpworker-inspector to monitor background jobs live on physical devices.

The full source code for the offline queue, SQLDelight schemas, and graph engine is open source on GitHub: neuralheads/kmpworker.

About the Author

N

The engineering team at Neural Heads, building the future of software one project at a time.

Share this post

Engineering Insights

Get the latest engineering insights and product updates from the studio.

No spam. Pure engineering. Unsubscribe anytime.