Let's get KMP WorkManager running in your project. Should take about 5 minutes.
Add KMP WorkManager to your build.gradle.kts (module level):
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.brewkits:kmpworkmanager:3.0.1")
// Optional — only if you use the built-in HTTP workers (Http*/ParallelHttp*).
// Requires Ktor 3; see docs/MIGRATION_V3.0.0.md.
implementation("dev.brewkits:kmpworkmanager-http:3.0.1")
}
}
}Sync your project with Gradle files.
Add these permissions to your AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for scheduling exact alarms (Android 12+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- Required for notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Required for heavy tasks using foreground services -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application>
<!-- Your app content -->
</application>
</manifest>Create or update your Application class:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// Initialize KmpWorkManager with your worker factory
// (Uses AndroidWorkerFactoryGenerated if you use kmpworker-ksp)
KmpWorkManager.initialize(
context = this,
workerFactory = AndroidWorkerFactoryGenerated()
)
}
}Update your AndroidManifest.xml to reference the Application class:
<application
android:name=".MyApp"
...>
</application>KMP WorkManager uses WorkManager internally, but you may want to add it explicitly:
androidMain.dependencies {
implementation("androidx.work:work-runtime-ktx:2.11.0")
}Since v2.4.1, KMP WorkManager supports Dynamic Task IDs on iOS — you no longer
need to declare each individual task ID. Only the two library dispatcher IDs are
required in BGTaskSchedulerPermittedIdentifiers:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>kmp_master_dispatcher_task</string>
<string>kmp_chain_executor_task</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
<string>fetch</string>
<string>remote-notification</string>
</array>Any task ID you pass to scheduler.enqueue(...) (e.g. user-123-sync) is routed
through the master dispatcher's internal queue and executed when iOS fires the
master dispatcher slot. See iOS Dynamic Task Scheduling
for the full mechanism.
Create or update your iOSApp.swift. You must register both dispatcher
identifiers with BGTaskScheduler. The library provides handleMasterDispatcherTask
and handleChainExecutorTask so you don't write any boilerplate.
KoinInitializerKt.doInitKoin(...)andKoinIOS()below are your own Swift bridges to the shared Koin graph (the demo app'sKoinIOS.ktis a working reference). They are not library APIs — wire them to match your project's DI setup.
import SwiftUI
import BackgroundTasks
import composeApp
@main
struct iOSApp: App {
init() {
// Initialize Koin — your iosModule must include
// kmpWorkerModule(workerFactory = IosWorkerFactoryGenerated())
KoinInitializerKt.doInitKoin(platformModule: IOSModuleKt.iosModule)
// Register background tasks
registerBackgroundTasks()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
private func registerBackgroundTasks() {
let koin = KoinIOS()
let scheduler = koin.getScheduler()
let dispatcher = koin.getDynamicTaskDispatcher()
let chainExecutor = koin.getChainExecutor()
// 1. Master dispatcher — handles every dynamic task ID
// (everything not pre-registered as its own BGTask identifier).
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_master_dispatcher_task",
using: nil
) { task in
IosBackgroundTaskHandler.shared.handleMasterDispatcherTask(
task: task,
dispatcher: dispatcher,
scheduler: scheduler
)
}
// 2. Chain executor — handles batched task chains.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_chain_executor_task",
using: nil
) { task in
IosBackgroundTaskHandler.shared.handleChainExecutorTask(
task: task,
chainExecutor: chainExecutor
)
}
}
}Both handlers are required.
kmp_master_dispatcher_taskis what wakes up your dynamic tasks; without it registered, every task ID not declared explicitly inInfo.plistwill never fire.BGTaskScheduleralso throwsNSInternalInconsistencyExceptionif an identifier appears inBGTaskSchedulerPermittedIdentifiersbut has no handler registered.
Add this extension to handle background task scheduling when app enters background:
extension iOSApp {
func scenePhase(_ phase: ScenePhase) {
if phase == .background {
// iOS will execute scheduled tasks when app is in background
print("App entered background - BGTasks can now execute")
}
}
}Now you're ready to schedule your first background task!
class MyViewModel(
private val scheduler: BackgroundTaskScheduler
) {
// Your code here
}Or get it from Koin directly:
val scheduler: BackgroundTaskScheduler = get()suspend fun scheduleDataSync() {
val result = scheduler.enqueue(
id = "data-sync",
trigger = TaskTrigger.Periodic(
intervalMs = 15 * 60 * 1000 // 15 minutes
),
workerClassName = "SyncWorker",
constraints = Constraints(
requiresNetwork = true,
requiresCharging = false
)
)
when (result) {
ScheduleResult.ACCEPTED -> println("Task scheduled successfully!")
ScheduleResult.REJECTED_OS_POLICY -> println("OS rejected the task (e.g. battery saver, permissions)")
ScheduleResult.DEADLINE_ALREADY_PASSED -> println("Scheduled time is in the past")
ScheduleResult.THROTTLED -> println("Too many tasks scheduled")
}
}suspend fun uploadFile() {
scheduler.enqueue(
id = "file-upload",
trigger = TaskTrigger.OneTime(
initialDelayMs = 0 // Execute immediately
),
workerClassName = "UploadWorker",
constraints = Constraints(
requiresNetwork = true,
networkType = NetworkType.UNMETERED, // WiFi only
backoffPolicy = BackoffPolicy.EXPONENTIAL,
backoffDelayMs = 10_000 // Retry after 10 seconds
)
)
}Implement the actual work that will be executed in the background. KMP WorkManager encourages writing the core logic in your commonMain module and using platform-specific wrappers.
We highly recommend using kmpworker-ksp to auto-generate the worker factories.
import dev.brewkits.kmpworkmanager.background.domain.Worker
import dev.brewkits.kmpworkmanager.background.domain.WorkerEnvironment
import dev.brewkits.kmpworkmanager.background.domain.WorkerResult
class SyncWorker : Worker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
return try {
// Your sync logic here
println("Syncing data from server...")
// Check for OS cancellation
if (env.isCancelled()) return WorkerResult.Failure("Cancelled")
WorkerResult.Success("Data synced successfully")
} catch (e: Exception) {
WorkerResult.Failure("Sync failed: ${e.message}")
}
}
}import dev.brewkits.kmpworkmanager.annotations.Worker
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
import dev.brewkits.kmpworkmanager.background.domain.WorkerEnvironment
import dev.brewkits.kmpworkmanager.background.domain.WorkerResult
@Worker(name = "SyncWorker")
class SyncWorkerAndroid : AndroidWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult =
SyncWorker().doWork(input, env)
}import dev.brewkits.kmpworkmanager.annotations.Worker
import dev.brewkits.kmpworkmanager.background.data.IosWorker
import dev.brewkits.kmpworkmanager.background.domain.WorkerEnvironment
import dev.brewkits.kmpworkmanager.background.domain.WorkerResult
@Worker(name = "SyncWorker", bgTaskId = "periodic-sync-task")
class SyncWorkerIos : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult =
SyncWorker().doWork(input, env)
}Note: The name value ("SyncWorker") must match the workerClassName you pass to scheduler.enqueue(...). Setting it explicitly also protects against silent breakage if ProGuard/R8 renames the wrapper class.
By annotating these with @Worker, the KSP processor generates AndroidWorkerFactoryGenerated and IosWorkerFactoryGenerated, which you already passed to KmpWorkManager.initialize() on Android and to kmpWorkerModule(workerFactory = …) (inside iosModule, invoked via KoinInitializerKt.doInitKoin(...)) on iOS.
That's it! You now have KMP WorkManager set up. Here's what you can do next:
- Explore all triggers - Learn about 9 different trigger types
- Build task chains - Execute sequential and parallel workflows
- Configure constraints - Fine-tune when tasks run
- Platform-specific setup - Advanced Android & iOS configuration
- API Reference - Complete API documentation
- Check WorkManager initialization: Ensure Koin is properly initialized
- Check permissions: Verify all required permissions are in AndroidManifest.xml
- Check constraints: Tasks won't run if constraints aren't met (e.g., no network)
- Check Doze mode: Test with
adb shell dumpsys battery unplugandadb shell dumpsys deviceidle force-idle
- Check Info.plist: Ensure
kmp_master_dispatcher_taskandkmp_chain_executor_taskare registered. - Check AppDelegate: Verify
registerBackgroundTasks()is called. - App must be in background: BGTasks only run when app is backgrounded.
- Test with simulator: Use
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"kmp_master_dispatcher_task"]in LLDB. - Check worker registration: Ensure worker is registered in
IosWorkerFactory.
Make sure you're collecting events from TaskEventBus:
@Composable
fun MyScreen() {
LaunchedEffect(Unit) {
TaskEventBus.events.collect { event ->
println("Task event: ${event.taskName} - ${event.message}")
}
}
}- Read the API Reference
- Check the Platform Setup Guide
- Browse GitHub Issues
- Ask in GitHub Discussions
Happy scheduling! 🚀