Back to blog
Kotlin MultiplatformAndroidiOSKMPBackground TasksWorkManagerBGTaskSchedulerOpen Source

Background Tasks in Kotlin Multiplatform: Unifying Android WorkManager and iOS BGTaskScheduler

N
Neural Heads Team
Engineering
September 1, 2026

Background Tasks in Kotlin Multiplatform: Unifying Android WorkManager and iOS BGTaskScheduler

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.

On Android you have Jetpack WorkManager, 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 BGTaskScheduler, Apple’s tightly controlled background execution framework, which gives you roughly 30 seconds for a refresh task and zero guarantees about when it will actually run.

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.

This is a walkthrough of KMPWorker, an open-source library we built at NeuralHeads to solve exactly this problem.


The Core Interface Problem

Before writing any Android or iOS code, the first decision was: what does a platform-agnostic task API actually look like?

The KmpWorker interface in core is the answer. It defines the contract that both AndroidKmpWorker and IOSKmpWorker implement:

interface KmpWorker {
    suspend fun enqueue(request: TaskRequest)
    suspend fun cancel(taskId: String)
    fun observe(taskId: String): Flow<TaskState>
    fun register(taskId: String, block: suspend () -> Unit)
    fun registerWithContext(taskId: String, block: suspend TaskExecutionContext.() -> Unit)
    suspend fun enqueueChain(chain: TaskChain, policy: ChainPolicy)
    // ...
}

The register / enqueue 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 BGTaskScheduler requires all identifiers to be registered with the OS before applicationDidFinishLaunching returns — you cannot register a task handler lazily at the point you want to run it.

TaskState flows through Kotlin’s Flow<TaskState>, covering the full lifecycle:

Scheduled → Running → Success
                    → Failed(throwable, retryCount, willRetry)
                    → Cancelled(reason)
                    → TimedOut(afterMillis)

Because this is a shared Flow, UI code in either Android or iOS (via Swift interop) can observe state changes reactively, without polling or callbacks.


How Android Maps to WorkManager

The Android implementation lives in AndroidTaskScheduler. For each TaskRequest, it constructs a OneTimeWorkRequest or PeriodicWorkRequest and delegates to WorkManager. The mapping is fairly direct for most cases.

TaskType is a sealed class:

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()
}

ExactTime maps to WorkManager’s setInitialDelay(). 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.

Windowed behaves similarly — the earliestMillis becomes the initial delay and the latestMillis is informational in the current Android implementation (WorkManager has a flex interval for PeriodicWorkRequest, but not directly for one-time tasks).

Constraints map cleanly to WorkManager’s Constraints.Builder:

private fun buildWorkConstraints(kmpConstraints: Constraints): androidx.work.Constraints {
    val builder = Constraints.Builder()
        .setRequiredNetworkType(
            when {
                kmpConstraints.requiresUnmeteredNetwork -> NetworkType.UNMETERED
                kmpConstraints.requiresNonRoamingNetwork -> NetworkType.NOT_ROAMING
                kmpConstraints.requiresInternet -> NetworkType.CONNECTED
                else -> NetworkType.NOT_REQUIRED
            }
        )
        .setRequiresCharging(kmpConstraints.requiresCharging)
        .setRequiresBatteryNotLow(kmpConstraints.batteryNotLow)
        .setRequiresDeviceIdle(kmpConstraints.requiresDeviceIdle)
    // ...
    return builder.build()
}

Network constraint resolution follows a priority order: requiresUnmeteredNetwork takes precedence over requiresNonRoamingNetwork, which takes precedence over requiresInternet. Only one NetworkType can be set in WorkManager, so the library resolves the most restrictive constraint.

The actual work runs inside KmpTaskWorker, which extends CoroutineWorker. This is where retry logic, timeout enforcement, and telemetry bridging happen:

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()
    }
}

One detail worth noting: the retry policy is serialized into WorkManager’s inputData as string constants, because WorkManager’s Data object only supports primitive types. The policy type, delay, and max retry count are stored as separate keys and reconstructed inside KmpTaskWorker.readRetryPolicy().


The iOS Side: What BGTaskScheduler Actually Constrains

iOS is harder. The IOSTaskScheduler uses BGTaskScheduler with two task types: BGAppRefreshTask for TaskType.OneTime, and BGProcessingTask for TaskType.Periodic.

The most important thing to understand about BGTaskScheduler — documented clearly in the repo’s docs/ios-limitations.md — is that the entire scheduling decision belongs to Apple:

What your app controlsWhat Apple controlsRequesting a task identifierWhether the task runs at allSetting earliestBeginDateWhen the task actually runsHandling the expiration callbackHow long the task gets to run

For BGAppRefreshTask, 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 TaskState.TimedOut when it fires, rather than leaving the task in an indeterminate state.

The Periodic task type maps to BGProcessingTask, 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 repeatIntervalMillis value is a hint to the system, not a contract.

The cancelByTag() implementation on iOS is a good example of where the platform diverges from Android’s model. WorkManager supports cancelling by tag natively. BGTaskScheduler only supports cancellation by identifier. So IOSKmpWorker.cancelByTag() 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.

If tag-level cancellation granularity matters for your use case on iOS, this is a limitation to design around.


State Broadcasting with TaskMonitor

State changes flow through TaskMonitor, a singleton that wraps a MutableSharedFlow:

private val states = MutableSharedFlow<Pair<String, TaskState>>(
    replay = 1,
    extraBufferCapacity = 64
)

The replay = 1 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 TaskState.Success rather than nothing.

extraBufferCapacity = 64 prevents slow collectors from back-pressuring the emitters. A background task running in KmpTaskWorker should never be blocked by a UI observer being slow to consume events.

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 EventStore mechanism. Terminal states (Success, Cancelled, Failed with willRetry = false) are written to the store before the in-memory emit, so even if the process dies immediately after writing, the event is safely on disk. TaskMonitor.replayPendingEvents() is then called at app startup to rebroadcast any events that weren’t delivered in the previous session.


Retry Engine

RetryEngine is stateless — a pure function that maps (retryCount, RetryPolicy) to a delay in milliseconds:

is RetryPolicy.Exponential -> {
    val maxDelay = Long.MAX_VALUE / 2
    val shift = retryCount.coerceIn(0, 62)
    val multiplier = 1L shl shift  // 2^shift
    if (multiplier > maxDelay / policy.initialDelayMillis.coerceAtLeast(1L)) {
        maxDelay
    } else {
        policy.initialDelayMillis * multiplier
    }
}

The overflow guard is deliberate. Without it, a long-running exponential backoff (say, 64+ retries) would overflow a Long and produce a negative delay. The implementation caps at Long.MAX_VALUE / 2 — a safe practical ceiling that prevents arithmetic errors without complicating the calling code.

The three available policies:

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
)

Task Chains and Step Persistence

For multi-step workflows where each step must complete before the next begins, TaskChain provides a sequenced execution model. The chain executor (TaskChainExecutor) observes TaskMonitor.observeAll() and advances to the next step on success.

What makes this non-trivial is crash safety. Before enqueueing step n+1, the chain executor calls chainRepository.updateStep(chain.id, nextStep, "RUNNING"). This means if the process is killed between step completions, restorePendingChains() at the next launch will resume from the last committed step rather than restarting from step 0.

Step task IDs are namespaced under the chain ID (${chain.id}:step:${index}) to avoid collisions with independently scheduled tasks.

The builder DSL makes common chains readable:

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)
    }
}

ChainPolicy.REPLACE cancels any existing chain with the same ID before starting a new one. ChainPolicy.KEEP skips enqueue if a chain with that ID is already running. ChainPolicy.ALLOW_DUPLICATE (the default) always enqueues, which is useful for chains where concurrent executions of different “runs” are intentional.


What’s Experimental and What’s Stable

The DAG (Directed Acyclic Graph) execution API — which allows independent nodes to run in parallel while respecting declared dependencies — is marked @OptIn(ExperimentalKmpWorkerApi::class). This means the API surface may change between releases. The chain API and the core KmpWorker interface are stable.

The transfer module (kmpworker-transfer) uses HttpURLConnection on Android and NSURLSession 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.


Getting Started

Add the umbrella artifact or pick specific modules:

// 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")
}

Android initialization is handled automatically via KmpWorkerInitializer, which uses the App Startup library to wire up the WorkManager factory without requiring any Application subclass code.

iOS requires explicit initialization in AppDelegate before the app finishes launching:

let kmpWorker = IOSKmpWorker()

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    kmpWorker.register(taskId: "sync") { /* your work */ }
    kmpWorker.initialize()
    return true
}

The full module reference and documentation are in the GitHub repository.


Where This Goes Next

The core scheduling and chain execution is stable. The areas still under active development include the DAG executor (experimental), the Compose Multiplatform live inspector (kmpworker-inspector), 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.

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.


KMPWorker is published under the Apache 2.0 license. Source is available at github.com/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.