<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[NeuralHeads]]></title><description><![CDATA[NeuralHeads]]></description><link>https://neuralheads.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69d5542f5da14bc70e86597b/943483e2-8e4b-4229-bfa5-dcd374d05c7d.png</url><title>NeuralHeads</title><link>https://neuralheads.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 09 Sep 2026 13:22:35 GMT</lastBuildDate><atom:link href="https://neuralheads.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Background Tasks in Kotlin Multiplatform: Unifying Android WorkManager and iOS BGTaskScheduler]]></title><description><![CDATA[Writing shared Kotlin code across Android and iOS is genuinely productive — until you need to schedule work that runs in the background. At that point the platforms diverge sharply, and the usual KMP ]]></description><link>https://neuralheads.hashnode.dev/background-tasks-in-kotlin-multiplatform-unifying-android-workmanager-and-ios-bgtaskscheduler</link><guid isPermaLink="true">https://neuralheads.hashnode.dev/background-tasks-in-kotlin-multiplatform-unifying-android-workmanager-and-ios-bgtaskscheduler</guid><category><![CDATA[Kotlin]]></category><category><![CDATA[ios app development]]></category><category><![CDATA[development]]></category><dc:creator><![CDATA[vanced youtube]]></dc:creator><pubDate>Wed, 02 Sep 2026 15:59:01 GMT</pubDate><content:encoded><![CDATA[<p>Writing shared Kotlin code across Android and iOS is genuinely productive — until you need to schedule work that runs in the background. At that point the platforms diverge sharply, and the usual KMP approach of "write once, adapt per platform" gets uncomfortable fast.</p>
<p>On Android you have <strong>Jetpack WorkManager</strong>, a robust, battle-tested API backed by the OS job scheduler. It handles constraints (network, charging, idle), exponential backoff, unique work policies, and periodic tasks. On iOS you have <strong>BGTaskScheduler</strong>, Apple's tightly controlled background execution framework, which gives you roughly 30 seconds for a refresh task and zero guarantees about <em>when</em> it will actually run.</p>
<p>These two systems are not just different APIs — they represent genuinely different philosophies about how much control an app should have over when its code runs. Getting a single Kotlin interface to sit cleanly over both, without leaking platform assumptions into shared code, requires some deliberate design choices.</p>
<p>This is a walkthrough of <a href="https://github.com/neuralheads/kmpworker">KMPWorker</a>, an open-source library we built at NeuralHeads to solve exactly this problem.</p>
<hr />
<h2>The Core Interface Problem</h2>
<p>Before writing any Android or iOS code, the first decision was: what does a platform-agnostic task API actually look like?</p>
<p>The <code>KmpWorker</code> interface in <code>core</code> is the answer. It defines the contract that both <code>AndroidKmpWorker</code> and <code>IOSKmpWorker</code> implement:</p>
<pre><code class="language-kotlin">interface KmpWorker {
    suspend fun enqueue(request: TaskRequest)
    suspend fun cancel(taskId: String)
    fun observe(taskId: String): Flow&lt;TaskState&gt;
    fun register(taskId: String, block: suspend () -&gt; Unit)
    fun registerWithContext(taskId: String, block: suspend TaskExecutionContext.() -&gt; Unit)
    suspend fun enqueueChain(chain: TaskChain, policy: ChainPolicy)
    // ...
}
</code></pre>
<p>The <code>register</code> / <code>enqueue</code> split is intentional. Handlers are registered at app startup (before enqueue ever gets called), and enqueue schedules the actual work. On iOS this matters because <code>BGTaskScheduler</code> requires all identifiers to be registered with the OS <em>before</em> <code>applicationDidFinishLaunching</code> returns — you cannot register a task handler lazily at the point you want to run it.</p>
<p><code>TaskState</code> flows through Kotlin's <code>Flow&lt;TaskState&gt;</code>, covering the full lifecycle:</p>
<pre><code class="language-plaintext">Scheduled → Running → Success
                    → Failed(throwable, retryCount, willRetry)
                    → Cancelled(reason)
                    → TimedOut(afterMillis)
</code></pre>
<p>Because this is a shared <code>Flow</code>, UI code in either Android or iOS (via Swift interop) can observe state changes reactively, without polling or callbacks.</p>
<hr />
<h2>How Android Maps to WorkManager</h2>
<p>The Android implementation lives in <code>AndroidTaskScheduler</code>. For each <code>TaskRequest</code>, it constructs a <code>OneTimeWorkRequest</code> or <code>PeriodicWorkRequest</code> and delegates to <code>WorkManager</code>. The mapping is fairly direct for most cases.</p>
<p><code>TaskType</code> is a sealed class:</p>
<pre><code class="language-kotlin">sealed class TaskType {
    data object OneTime : TaskType()
    data class Periodic(val repeatIntervalMillis: Long) : TaskType()
    data class ExactTime(val runAtMillis: Long) : TaskType()
    data class Windowed(val earliestMillis: Long, val latestMillis: Long) : TaskType()
}
</code></pre>
<p><code>ExactTime</code> maps to WorkManager's <code>setInitialDelay()</code>. This is worth calling out explicitly: WorkManager does not offer hard exact scheduling. The actual execution happens at or after the specified time, subject to battery optimizations and Doze mode. If your use case genuinely requires millisecond-precise execution, WorkManager is the wrong tool on Android regardless of any abstraction layer on top.</p>
<p><code>Windowed</code> behaves similarly — the <code>earliestMillis</code> becomes the initial delay and the <code>latestMillis</code> is informational in the current Android implementation (WorkManager has a flex interval for <code>PeriodicWorkRequest</code>, but not directly for one-time tasks).</p>
<p>Constraints map cleanly to WorkManager's <code>Constraints.Builder</code>:</p>
<pre><code class="language-kotlin">private fun buildWorkConstraints(kmpConstraints: Constraints): androidx.work.Constraints {
    val builder = Constraints.Builder()
        .setRequiredNetworkType(
            when {
                kmpConstraints.requiresUnmeteredNetwork -&gt; NetworkType.UNMETERED
                kmpConstraints.requiresNonRoamingNetwork -&gt; NetworkType.NOT_ROAMING
                kmpConstraints.requiresInternet -&gt; NetworkType.CONNECTED
                else -&gt; NetworkType.NOT_REQUIRED
            }
        )
        .setRequiresCharging(kmpConstraints.requiresCharging)
        .setRequiresBatteryNotLow(kmpConstraints.batteryNotLow)
        .setRequiresDeviceIdle(kmpConstraints.requiresDeviceIdle)
    // ...
    return builder.build()
}
</code></pre>
<p>Network constraint resolution follows a priority order: <code>requiresUnmeteredNetwork</code> takes precedence over <code>requiresNonRoamingNetwork</code>, which takes precedence over <code>requiresInternet</code>. Only one <code>NetworkType</code> can be set in WorkManager, so the library resolves the most restrictive constraint.</p>
<p>The actual work runs inside <code>KmpTaskWorker</code>, which extends <code>CoroutineWorker</code>. This is where retry logic, timeout enforcement, and telemetry bridging happen:</p>
<pre><code class="language-kotlin">override suspend fun doWork(): Result {
    val taskId = inputData.getString(KEY_TASK_ID) ?: return Result.failure()
    // ...
    return try {
        TaskMonitor.emit(taskId, TaskState.Running())
        if (timeout != null) {
            withTimeout(timeout) { TaskRegistry.execute(taskId, ctx) }
        } else {
            TaskRegistry.execute(taskId, ctx)
        }
        TaskMonitor.emit(taskId, TaskState.Success)
        Result.success()
    } catch (e: TimeoutCancellationException) {
        TaskMonitor.emit(taskId, TaskState.TimedOut(afterMillis = elapsed))
        Result.failure()
    } catch (e: Exception) {
        val willRetry = RetryEngine.shouldRetry(retryCount, retryPolicy)
        TaskMonitor.emit(taskId, TaskState.Failed(e, retryCount, willRetry))
        if (willRetry) Result.retry() else Result.failure()
    }
}
</code></pre>
<p>One detail worth noting: the retry policy is serialized into WorkManager's <code>inputData</code> as string constants, because WorkManager's <code>Data</code> object only supports primitive types. The policy type, delay, and max retry count are stored as separate keys and reconstructed inside <code>KmpTaskWorker.readRetryPolicy()</code>.</p>
<hr />
<h2>The iOS Side: What BGTaskScheduler Actually Constrains</h2>
<p>iOS is harder. The <code>IOSTaskScheduler</code> uses <code>BGTaskScheduler</code> with two task types: <code>BGAppRefreshTask</code> for <code>TaskType.OneTime</code>, and <code>BGProcessingTask</code> for <code>TaskType.Periodic</code>.</p>
<p>The most important thing to understand about <code>BGTaskScheduler</code> — documented clearly in the repo's <code>docs/ios-limitations.md</code> — is that the <em>entire scheduling decision belongs to Apple</em>:</p>
<table>
<thead>
<tr>
<th>What your app controls</th>
<th>What Apple controls</th>
</tr>
</thead>
<tbody><tr>
<td>Requesting a task identifier</td>
<td>Whether the task runs at all</td>
</tr>
<tr>
<td>Setting <code>earliestBeginDate</code></td>
<td>When the task actually runs</td>
</tr>
<tr>
<td>Handling the expiration callback</td>
<td>How long the task gets to run</td>
</tr>
</tbody></table>
<p>For <code>BGAppRefreshTask</code>, your handler gets approximately 30 seconds. Apple enforces this with an expiration handler that fires when the budget runs out. The iOS scheduler in KMPWorker registers this expiration handler and emits <code>TaskState.TimedOut</code> when it fires, rather than leaving the task in an indeterminate state.</p>
<p>The <code>Periodic</code> task type maps to <code>BGProcessingTask</code>, which typically only runs when the device is connected to power and idle. It gets a longer execution window than a refresh task, but comes with stricter system preconditions. There is currently no way to guarantee that a periodic KMPWorker task will run at a specific interval on iOS — the <code>repeatIntervalMillis</code> value is a <em>hint to the system</em>, not a contract.</p>
<p>The <code>cancelByTag()</code> implementation on iOS is a good example of where the platform diverges from Android's model. WorkManager supports cancelling by tag natively. <code>BGTaskScheduler</code> only supports cancellation by identifier. So <code>IOSKmpWorker.cancelByTag()</code> currently cancels all registered tasks, which is a broader operation than what Android's implementation does. This is called out explicitly in the source with a warning log.</p>
<p>If tag-level cancellation granularity matters for your use case on iOS, this is a limitation to design around.</p>
<hr />
<h2>State Broadcasting with TaskMonitor</h2>
<p>State changes flow through <code>TaskMonitor</code>, a singleton that wraps a <code>MutableSharedFlow</code>:</p>
<pre><code class="language-kotlin">private val states = MutableSharedFlow&lt;Pair&lt;String, TaskState&gt;&gt;(
    replay = 1,
    extraBufferCapacity = 64
)
</code></pre>
<p>The <code>replay = 1</code> is important: new collectors immediately receive the last emitted state for any task, without waiting for the next emission. This means a UI screen that navigates to a task-detail view after the task has already completed will still see <code>TaskState.Success</code> rather than nothing.</p>
<p><code>extraBufferCapacity = 64</code> prevents slow collectors from back-pressuring the emitters. A background task running in <code>KmpTaskWorker</code> should never be blocked by a UI observer being slow to consume events.</p>
<p>For apps that need state to survive process termination — common for sync tasks that need to surface completion even if the user relaunched the app — there's an optional <code>EventStore</code> mechanism. Terminal states (<code>Success</code>, <code>Cancelled</code>, <code>Failed</code> with <code>willRetry = false</code>) are written to the store <em>before</em> the in-memory emit, so even if the process dies immediately after writing, the event is safely on disk. <code>TaskMonitor.replayPendingEvents()</code> is then called at app startup to rebroadcast any events that weren't delivered in the previous session.</p>
<hr />
<h2>Retry Engine</h2>
<p><code>RetryEngine</code> is stateless — a pure function that maps <code>(retryCount, RetryPolicy)</code> to a delay in milliseconds:</p>
<pre><code class="language-kotlin">is RetryPolicy.Exponential -&gt; {
    val maxDelay = Long.MAX_VALUE / 2
    val shift = retryCount.coerceIn(0, 62)
    val multiplier = 1L shl shift  // 2^shift
    if (multiplier &gt; maxDelay / policy.initialDelayMillis.coerceAtLeast(1L)) {
        maxDelay
    } else {
        policy.initialDelayMillis * multiplier
    }
}
</code></pre>
<p>The overflow guard is deliberate. Without it, a long-running exponential backoff (say, 64+ retries) would overflow a <code>Long</code> and produce a negative delay. The implementation caps at <code>Long.MAX_VALUE / 2</code> — a safe practical ceiling that prevents arithmetic errors without complicating the calling code.</p>
<p>The three available policies:</p>
<pre><code class="language-kotlin">RetryPolicy.None                                  // no retry
RetryPolicy.Linear(delayMillis = 5_000)           // fixed 5s between attempts
RetryPolicy.Exponential(
    initialDelayMillis = 5_000,                   // attempt 1: 5s
    maxRetries = 5                                // attempt 2: 10s, 3: 20s, 4: 40s, 5: 80s
)
</code></pre>
<hr />
<h2>Task Chains and Step Persistence</h2>
<p>For multi-step workflows where each step must complete before the next begins, <code>TaskChain</code> provides a sequenced execution model. The chain executor (<code>TaskChainExecutor</code>) observes <code>TaskMonitor.observeAll()</code> and advances to the next step on success.</p>
<p>What makes this non-trivial is crash safety. Before enqueueing step <code>n+1</code>, the chain executor calls <code>chainRepository.updateStep(chain.id, nextStep, "RUNNING")</code>. This means if the process is killed between step completions, <code>restorePendingChains()</code> at the next launch will resume from the last committed step rather than restarting from step 0.</p>
<p>Step task IDs are namespaced under the chain ID (<code>${chain.id}:step:${index}</code>) to avoid collisions with independently scheduled tasks.</p>
<p>The builder DSL makes common chains readable:</p>
<pre><code class="language-kotlin">kmpWorker.chain("onboarding", policy = ChainPolicy.REPLACE) {
    beginWith("fetch-profile")
    then("upload-avatar") {
        constraints = Constraints(requiresInternet = true)
    }
    then("notify-server") {
        retryPolicy = RetryPolicy.Exponential(5_000, 3)
    }
}
</code></pre>
<p><code>ChainPolicy.REPLACE</code> cancels any existing chain with the same ID before starting a new one. <code>ChainPolicy.KEEP</code> skips enqueue if a chain with that ID is already running. <code>ChainPolicy.ALLOW_DUPLICATE</code> (the default) always enqueues, which is useful for chains where concurrent executions of different "runs" are intentional.</p>
<hr />
<h2>What's Experimental and What's Stable</h2>
<p>The DAG (Directed Acyclic Graph) execution API — which allows independent nodes to run in parallel while respecting declared dependencies — is marked <code>@OptIn(ExperimentalKmpWorkerApi::class)</code>. This means the API surface may change between releases. The chain API and the core <code>KmpWorker</code> interface are stable.</p>
<p>The transfer module (<code>kmpworker-transfer</code>) uses <code>HttpURLConnection</code> on Android and <code>NSURLSession</code> on iOS for resumable background downloads and uploads, without pulling in Ktor. This avoids adding a heavyweight dependency just for HTTP, but it also means the transfer module lacks Ktor's interceptor model and authentication abstractions. If your use case involves complex auth flows or middleware, you'd likely want to layer your own HTTP client on top of KMPWorker's task scheduling rather than using the transfer module directly.</p>
<hr />
<h2>Getting Started</h2>
<p>Add the umbrella artifact or pick specific modules:</p>
<pre><code class="language-kotlin">// shared module build.gradle.kts
commonMain.dependencies {
    implementation("io.neuralheads.kmpworker:kmpworker-core:0.1.0")
}
androidMain.dependencies {
    implementation("io.neuralheads.kmpworker:kmpworker-android:0.1.0")
}
iosMain.dependencies {
    implementation("io.neuralheads.kmpworker:kmpworker-ios:0.1.0")
}
</code></pre>
<p>Android initialization is handled automatically via <code>KmpWorkerInitializer</code>, which uses the App Startup library to wire up the WorkManager factory without requiring any <code>Application</code> subclass code.</p>
<p>iOS requires explicit initialization in <code>AppDelegate</code> before the app finishes launching:</p>
<pre><code class="language-swift">let kmpWorker = IOSKmpWorker()

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -&gt; Bool {
    kmpWorker.register(taskId: "sync") { /* your work */ }
    kmpWorker.initialize()
    return true
}
</code></pre>
<p>The full module reference and documentation are in the <a href="https://github.com/neuralheads/kmpworker">GitHub repository</a>.</p>
<hr />
<h2>Where This Goes Next</h2>
<p>The core scheduling and chain execution is stable. The areas still under active development include the DAG executor (experimental), the Compose Multiplatform live inspector (<code>kmpworker-inspector</code>), and expanding the transfer module's error handling. The next article in this series covers the offline queue and persistence architecture — specifically how SQLDelight is used to ensure tasks survive app termination and network disconnections.</p>
<p>If you're building a KMP app that needs reliable background work across both platforms, KMPWorker gives you a starting point that handles the platform-specific wiring so your shared code does not have to.</p>
<hr />
<p><em>KMPWorker is published under the Apache 2.0 license. Source is available at</em> <a href="https://github.com/neuralheads/kmpworker"><em>github.com/neuralheads/kmpworker</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>