
A Kotlin Multiplatform library for managing background work and scheduling. It provides a unified API similar to Android Jetpack WorkManager, supporting Android, iOS, and Desktop targets.
Most KMP libraries wrap the happy path. This one was written after hitting the edge cases.
iOS BGTaskScheduler is not just "a different API" — it has a credit system that punishes apps that overrun their time budget, an opaque scheduling policy, and no recovery mechanism for incomplete work. Getting it wrong means your tasks silently stop running.
This library handles the scheduling. Your workers handle the work.
// Schedule once. Runs on Android (WorkManager) and iOS (BGTaskScheduler).
scheduler.enqueue(
id = "nightly-sync",
trigger = TaskTrigger.Periodic(intervalMs = 15 * 60 * 1000L),
workerClassName = "SyncWorker",
constraints = Constraints(requiresNetwork = true)
)// Multi-step workflows that survive process death.
// If step 47 of 100 was running when iOS killed the app —
// the next BGTask invocation resumes at step 47, not step 0.
scheduler.beginWith(TaskRequest("FetchUser"))
.then(TaskRequest("ProcessData"))
.then(TaskRequest("SyncToServer"))
.then(TaskRequest("UpdateLocalCache"))
.enqueue()Completed step indices are persisted to disk after every step. On resume, the executor reads the progress file and skips already-completed steps. A 100-step chain interrupted at step 47 continues from step 47 — exactly once, no duplicates.
Chains retry up to 3 times on failure. After 3 failures, the chain is abandoned and its state is cleaned up.
BGTaskScheduler gives your app a time window. If you consistently overrun it, iOS reduces how often your tasks are scheduled — silently. The chain executor uses adaptive time budgeting: it measures how long cleanup takes historically and reserves 15–30% of the budget as a safety margin, adjusting per run. Tasks that would exceed the remaining window are deferred to the next BGTask invocation rather than running over.
The task queue uses a binary format with per-record CRC32 checksums. When a corrupted record is detected (incomplete write, flash wear, abrupt power loss), the queue truncates at the corruption point and preserves all valid records before it. Nothing is silently lost, and nothing causes a crash on read.
iOS calls the BGTask expiration handler on a separate thread, which races with the executor's own shutdown logic. The library uses AtomicInt for the shutdown flag (not Mutex) to avoid blocking on I/O during the OS-mandated shutdown window.
// build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:kmpworkmanager:2.3.7")
}class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
KmpWorkManager.initialize(this)
}
}1. Worker factory (iosMain):
class AppWorkerFactory : IosWorkerFactory {
override fun createWorker(workerClassName: String): IosWorker? = when (workerClassName) {
"SyncWorker" -> SyncWorkerIos()
"UploadWorker" -> UploadWorkerIos()
else -> null
}
}2. AppDelegate:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
override init() {
super.init()
KoinInitializerKt.doInitKoin(platformModule: IOSModuleKt.iosModule)
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_chain_executor_task",
using: nil
) { task in
IosBackgroundTaskHandlerKt.handleChainExecutorTask(task as! BGProcessingTask)
}
return true
}
}3. Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>kmp_chain_executor_task</string>
</array>Full setup: docs/platform-setup.md
// commonMain — shared logic
class SyncWorker : CommonWorker {
override suspend fun doWork(input: String?): WorkerResult {
val items = api.fetchPendingItems()
database.upsert(items)
return WorkerResult.Success(
message = "Synced ${items.size} items",
data = mapOf("count" to items.size)
)
}
}// androidMain
class SyncWorkerAndroid : AndroidWorker {
override suspend fun doWork(input: String?) = SyncWorker().doWork(input)
}
// iosMain
class SyncWorkerIos : IosWorker {
override suspend fun doWork(input: String?) = SyncWorker().doWork(input)
}val scheduler = BackgroundTaskScheduler()
// Periodic — every 15 minutes when network is available
scheduler.enqueue(
id = "sync",
trigger = TaskTrigger.Periodic(intervalMs = 900_000),
workerClassName = "SyncWorker",
constraints = Constraints(requiresNetwork = true),
inputData = """{"userId": "u_123"}"""
)
// One-time with delay
scheduler.enqueue(
id = "post-login-sync",
trigger = TaskTrigger.OneTime(initialDelayMs = 5_000),
workerClassName = "SyncWorker"
)
// Exact time (Android: AlarmManager; iOS: best-effort)
scheduler.enqueue(
id = "morning-report",
trigger = TaskTrigger.Exact(atEpochMillis = tomorrowAt8am),
workerClassName = "ReportWorker"
)| Trigger | Android | iOS | Notes |
|---|---|---|---|
OneTime(delayMs) |
WorkManager | BGTaskScheduler | Minimum delay may be enforced by OS |
Periodic(intervalMs) |
WorkManager | BGTaskScheduler | Min 15 min on both platforms |
Exact(epochMs) |
AlarmManager (setExactAndAllowWhileIdle) |
Best-effort | iOS cannot guarantee exact timing |
Windowed(earliest, latest) |
WorkManager with delay | BGTaskScheduler | Preferred over Exact on iOS |
ContentUri(uri) |
WorkManager ContentUriTrigger | — | Android only (@AndroidOnly) |
| Android | iOS | |
|---|---|---|
| Scheduling | Deterministic via WorkManager | Opportunistic — OS decides when |
| Exact timing | ✅ AlarmManager | |
| Network constraint | ✅ Enforced | |
| Runs after force-quit | ✅ | ❌ Force-quit clears all pending tasks |
| Runs in background | ✅ | |
| Chain recovery | ✅ WorkContinuation | ✅ Step-level persistence |
| Time budget | No limit | ~30s (APP_REFRESH) / ~5min (PROCESSING) |
iOS note: If your app's background tasks are not running, check that you are not calling BGTaskScheduler.shared.submit() from the main thread, and that your Info.plist identifiers exactly match what you register. iOS provides no diagnostic feedback for misconfiguration.
Chains execute steps sequentially. Each step completes before the next begins.
scheduler.beginWith(
TaskRequest(workerClassName = "DownloadWorker", inputData = """{"url": "$fileUrl"}""")
).then(
TaskRequest(workerClassName = "ValidateWorker")
).then(
TaskRequest(workerClassName = "TranscodeWorker")
).then(
TaskRequest(workerClassName = "UploadWorker", inputData = """{"bucket": "processed"}""")
).enqueue()State model:
Chain definition (steps[]) ──────────────────────────────── stored on disk
Chain progress (completedSteps[]) ───── updated after every step ─ stored on disk
After process kill:
nextStep = first index NOT in completedSteps
→ execution resumes there, no re-runs, no skips
Steps are idempotent by design. If the same step index is completed twice (unlikely but possible on crash-during-write), the second completion is a no-op.
Ready to use with scheduler.enqueue(workerClassName = "HttpRequestWorker", ...).
| Worker | Purpose |
|---|---|
HttpRequestWorker |
HTTP request with configurable method, headers, body |
HttpDownloadWorker |
File download to local storage |
HttpUploadWorker |
Multipart file upload |
HttpSyncWorker |
Fetch-and-persist data sync |
FileCompressionWorker |
File compression (requires ZIPFoundation on iOS) |
Input/output passed as JSON via inputData / WorkerResult.data.
SSRF protection — outbound HTTP requests made by built-in workers are validated against a blocklist of internal/cloud-metadata endpoints before dispatch:
169.254.169.254 AWS/GCP/Azure IMDS
fd00:ec2::254 AWS EC2 (IPv6)
100.100.100.200 Alibaba Cloud ECS metadata
localhost, 0.0.0.0, [::1], 10.x, 172.16–31.x, 192.168.x
Input size validation — Android WorkManager's Data object has a 10 KB hard limit. Inputs exceeding 10 KB throw IllegalArgumentException at enqueue time, before WorkManager sees them.
Custom workers making outbound requests should use SecurityValidator if needed.
562 tests across commonTest, iosTest, androidInstrumentedTest
Notable test coverage:
QA_PersistenceResilienceTest — 100-step chain force-killed at step 50, verified to resume at exactly step 50 with no duplicate executionsV236ChainExecutorTest — ChainExecutor regression tests for time budget, shutdown propagation, and batch loop correctnessAppendOnlyQueueTest — CRC32 corruption detection, truncation recovery, concurrent read/writeAdaptiveTimeBudgetTest — BGTask time budget calculation under various deadline scenarios| Quick Start | Running in 5 minutes |
| Platform Setup | Android & iOS configuration |
| API Reference | Full public API |
| Task Chains | Chain API and recovery semantics |
| Built-in Workers | Worker reference and input schema |
| Constraints & Triggers | All scheduling options |
| iOS Best Practices | BGTask gotchas and recommendations |
| Troubleshooting | Common issues |
| CHANGELOG | Release history |
Migration: v2.2.2 · v2.3.0 · v2.3.3 → v2.3.4
| Kotlin | 2.1.0+ |
| Android | 8.0+ (API 26) |
| iOS | 13.0+ |
| Gradle | 8.0+ |
Before opening a PR, run:
./gradlew :kmpworker:allTests # all platforms must passCommit messages follow Conventional Commits. This keeps the CHANGELOG accurate and version bumps automated:
feat: add exponential backoff to HttpRequestWorker
fix: chain executor deadlock when BGTask expires during flush
security: validate redirects in HttpDownloadWorker
Apache 2.0. See LICENSE.
Most KMP libraries wrap the happy path. This one was written after hitting the edge cases.
iOS BGTaskScheduler is not just "a different API" — it has a credit system that punishes apps that overrun their time budget, an opaque scheduling policy, and no recovery mechanism for incomplete work. Getting it wrong means your tasks silently stop running.
This library handles the scheduling. Your workers handle the work.
// Schedule once. Runs on Android (WorkManager) and iOS (BGTaskScheduler).
scheduler.enqueue(
id = "nightly-sync",
trigger = TaskTrigger.Periodic(intervalMs = 15 * 60 * 1000L),
workerClassName = "SyncWorker",
constraints = Constraints(requiresNetwork = true)
)// Multi-step workflows that survive process death.
// If step 47 of 100 was running when iOS killed the app —
// the next BGTask invocation resumes at step 47, not step 0.
scheduler.beginWith(TaskRequest("FetchUser"))
.then(TaskRequest("ProcessData"))
.then(TaskRequest("SyncToServer"))
.then(TaskRequest("UpdateLocalCache"))
.enqueue()Completed step indices are persisted to disk after every step. On resume, the executor reads the progress file and skips already-completed steps. A 100-step chain interrupted at step 47 continues from step 47 — exactly once, no duplicates.
Chains retry up to 3 times on failure. After 3 failures, the chain is abandoned and its state is cleaned up.
BGTaskScheduler gives your app a time window. If you consistently overrun it, iOS reduces how often your tasks are scheduled — silently. The chain executor uses adaptive time budgeting: it measures how long cleanup takes historically and reserves 15–30% of the budget as a safety margin, adjusting per run. Tasks that would exceed the remaining window are deferred to the next BGTask invocation rather than running over.
The task queue uses a binary format with per-record CRC32 checksums. When a corrupted record is detected (incomplete write, flash wear, abrupt power loss), the queue truncates at the corruption point and preserves all valid records before it. Nothing is silently lost, and nothing causes a crash on read.
iOS calls the BGTask expiration handler on a separate thread, which races with the executor's own shutdown logic. The library uses AtomicInt for the shutdown flag (not Mutex) to avoid blocking on I/O during the OS-mandated shutdown window.
// build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:kmpworkmanager:2.3.7")
}class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
KmpWorkManager.initialize(this)
}
}1. Worker factory (iosMain):
class AppWorkerFactory : IosWorkerFactory {
override fun createWorker(workerClassName: String): IosWorker? = when (workerClassName) {
"SyncWorker" -> SyncWorkerIos()
"UploadWorker" -> UploadWorkerIos()
else -> null
}
}2. AppDelegate:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
override init() {
super.init()
KoinInitializerKt.doInitKoin(platformModule: IOSModuleKt.iosModule)
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_chain_executor_task",
using: nil
) { task in
IosBackgroundTaskHandlerKt.handleChainExecutorTask(task as! BGProcessingTask)
}
return true
}
}3. Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>kmp_chain_executor_task</string>
</array>Full setup: docs/platform-setup.md
// commonMain — shared logic
class SyncWorker : CommonWorker {
override suspend fun doWork(input: String?): WorkerResult {
val items = api.fetchPendingItems()
database.upsert(items)
return WorkerResult.Success(
message = "Synced ${items.size} items",
data = mapOf("count" to items.size)
)
}
}// androidMain
class SyncWorkerAndroid : AndroidWorker {
override suspend fun doWork(input: String?) = SyncWorker().doWork(input)
}
// iosMain
class SyncWorkerIos : IosWorker {
override suspend fun doWork(input: String?) = SyncWorker().doWork(input)
}val scheduler = BackgroundTaskScheduler()
// Periodic — every 15 minutes when network is available
scheduler.enqueue(
id = "sync",
trigger = TaskTrigger.Periodic(intervalMs = 900_000),
workerClassName = "SyncWorker",
constraints = Constraints(requiresNetwork = true),
inputData = """{"userId": "u_123"}"""
)
// One-time with delay
scheduler.enqueue(
id = "post-login-sync",
trigger = TaskTrigger.OneTime(initialDelayMs = 5_000),
workerClassName = "SyncWorker"
)
// Exact time (Android: AlarmManager; iOS: best-effort)
scheduler.enqueue(
id = "morning-report",
trigger = TaskTrigger.Exact(atEpochMillis = tomorrowAt8am),
workerClassName = "ReportWorker"
)| Trigger | Android | iOS | Notes |
|---|---|---|---|
OneTime(delayMs) |
WorkManager | BGTaskScheduler | Minimum delay may be enforced by OS |
Periodic(intervalMs) |
WorkManager | BGTaskScheduler | Min 15 min on both platforms |
Exact(epochMs) |
AlarmManager (setExactAndAllowWhileIdle) |
Best-effort | iOS cannot guarantee exact timing |
Windowed(earliest, latest) |
WorkManager with delay | BGTaskScheduler | Preferred over Exact on iOS |
ContentUri(uri) |
WorkManager ContentUriTrigger | — | Android only (@AndroidOnly) |
| Android | iOS | |
|---|---|---|
| Scheduling | Deterministic via WorkManager | Opportunistic — OS decides when |
| Exact timing | ✅ AlarmManager | |
| Network constraint | ✅ Enforced | |
| Runs after force-quit | ✅ | ❌ Force-quit clears all pending tasks |
| Runs in background | ✅ | |
| Chain recovery | ✅ WorkContinuation | ✅ Step-level persistence |
| Time budget | No limit | ~30s (APP_REFRESH) / ~5min (PROCESSING) |
iOS note: If your app's background tasks are not running, check that you are not calling BGTaskScheduler.shared.submit() from the main thread, and that your Info.plist identifiers exactly match what you register. iOS provides no diagnostic feedback for misconfiguration.
Chains execute steps sequentially. Each step completes before the next begins.
scheduler.beginWith(
TaskRequest(workerClassName = "DownloadWorker", inputData = """{"url": "$fileUrl"}""")
).then(
TaskRequest(workerClassName = "ValidateWorker")
).then(
TaskRequest(workerClassName = "TranscodeWorker")
).then(
TaskRequest(workerClassName = "UploadWorker", inputData = """{"bucket": "processed"}""")
).enqueue()State model:
Chain definition (steps[]) ──────────────────────────────── stored on disk
Chain progress (completedSteps[]) ───── updated after every step ─ stored on disk
After process kill:
nextStep = first index NOT in completedSteps
→ execution resumes there, no re-runs, no skips
Steps are idempotent by design. If the same step index is completed twice (unlikely but possible on crash-during-write), the second completion is a no-op.
Ready to use with scheduler.enqueue(workerClassName = "HttpRequestWorker", ...).
| Worker | Purpose |
|---|---|
HttpRequestWorker |
HTTP request with configurable method, headers, body |
HttpDownloadWorker |
File download to local storage |
HttpUploadWorker |
Multipart file upload |
HttpSyncWorker |
Fetch-and-persist data sync |
FileCompressionWorker |
File compression (requires ZIPFoundation on iOS) |
Input/output passed as JSON via inputData / WorkerResult.data.
SSRF protection — outbound HTTP requests made by built-in workers are validated against a blocklist of internal/cloud-metadata endpoints before dispatch:
169.254.169.254 AWS/GCP/Azure IMDS
fd00:ec2::254 AWS EC2 (IPv6)
100.100.100.200 Alibaba Cloud ECS metadata
localhost, 0.0.0.0, [::1], 10.x, 172.16–31.x, 192.168.x
Input size validation — Android WorkManager's Data object has a 10 KB hard limit. Inputs exceeding 10 KB throw IllegalArgumentException at enqueue time, before WorkManager sees them.
Custom workers making outbound requests should use SecurityValidator if needed.
562 tests across commonTest, iosTest, androidInstrumentedTest
Notable test coverage:
QA_PersistenceResilienceTest — 100-step chain force-killed at step 50, verified to resume at exactly step 50 with no duplicate executionsV236ChainExecutorTest — ChainExecutor regression tests for time budget, shutdown propagation, and batch loop correctnessAppendOnlyQueueTest — CRC32 corruption detection, truncation recovery, concurrent read/writeAdaptiveTimeBudgetTest — BGTask time budget calculation under various deadline scenarios| Quick Start | Running in 5 minutes |
| Platform Setup | Android & iOS configuration |
| API Reference | Full public API |
| Task Chains | Chain API and recovery semantics |
| Built-in Workers | Worker reference and input schema |
| Constraints & Triggers | All scheduling options |
| iOS Best Practices | BGTask gotchas and recommendations |
| Troubleshooting | Common issues |
| CHANGELOG | Release history |
Migration: v2.2.2 · v2.3.0 · v2.3.3 → v2.3.4
| Kotlin | 2.1.0+ |
| Android | 8.0+ (API 26) |
| iOS | 13.0+ |
| Gradle | 8.0+ |
Before opening a PR, run:
./gradlew :kmpworker:allTests # all platforms must passCommit messages follow Conventional Commits. This keeps the CHANGELOG accurate and version bumps automated:
feat: add exponential backoff to HttpRequestWorker
fix: chain executor deadlock when BGTask expires during flush
security: validate redirects in HttpDownloadWorker
Apache 2.0. See LICENSE.