Kmpworkmanager
KMP WorkManager - Unified API for scheduling and managing background tasks—one‑off, periodic, exact and chained jobs—featuring advanced triggers, structured logging, event‑driven completion, demo UI and docs.
Install / Use
/learn @brewkits/KmpworkmanagerQuality Score
Category
Development & EngineeringSupported Platforms
Tags
README
KMP WorkManager
Background task scheduling for Kotlin Multiplatform — including the parts iOS makes hard.
</div>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()
What this solves
Chain recovery after process kill
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.
iOS time budget management
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.
Queue integrity
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.
Thread safety during expiry
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.
Installation
// build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:kmpworkmanager:2.3.7")
}
<details>
<summary><b>Android setup</b></summary>
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
KmpWorkManager.initialize(this)
}
}
</details>
<details>
<summary><b>iOS setup</b></summary>
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
</details>Quick start
Define a worker
// 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)
}
Schedule it
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"
)
Triggers
| 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) |
Platform behavior
| | Android | iOS | |---|---------|-----| | Scheduling | Deterministic via WorkManager | Opportunistic — OS decides when | | Exact timing | ✅ AlarmManager | ⚠️ Best-effort, may be hours late | | Network constraint | ✅ Enforced | ⚠️ Advisory only | | Runs after force-quit | ✅ | ❌ Force-quit clears all pending tasks | | Runs in background | ✅ | ⚠️ Only when OS grants BGTask window | | 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.
Task chains
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.
Built-in workers
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.
Security
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.
Testing
562 tests across commonTest, iosTest
