Comprehensive guide for configuring KMP WorkManager on Android and iOS.
Add to your build.gradle.kts:
kotlin {
sourceSets {
androidMain.dependencies {
// KMP WorkManager (required)
implementation("dev.brewkits:kmpworkmanager:2.5.0")
// WorkManager (optional - already included transitively)
implementation("androidx.work:work-runtime-ktx:2.11.0")
// For Kotlin coroutines support
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
}
}
}<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Schedule exact alarms (Android 12+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- Post notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Foreground service for heavy tasks -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Wake lock (for exact alarms) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Internet (if your tasks need network) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".KMPWorkManagerApp"
...>
<!-- WorkManager Worker -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup" />
</provider>
<!-- Alarm Receiver for exact alarms -->
<receiver
android:name="dev.brewkits.kmpworkmanager.sample.background.data.AlarmReceiver"
android:enabled="true"
android:exported="false" />
</application>
</manifest>Create an Application class to initialize the library:
class KMPWorkManagerApp : 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()
)
}
}Register in AndroidManifest.xml:
<application
android:name=".KMPWorkManagerApp"
...>
</application>For Android 13+, request notification permission at runtime:
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
println("Notification permission granted")
} else {
println("Notification permission denied")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS
)
}
// Request exact alarm permission for Android 12+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = getSystemService(AlarmManager::class.java)
if (!alarmManager.canScheduleExactAlarms()) {
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).also {
startActivity(it)
}
}
}
}
}Add to proguard-rules.pro:
# Keep WorkManager classes
-keep class androidx.work.** { *; }
-keep class dev.brewkits.kmpworkmanager.sample.background.** { *; }
# Keep Kotlin coroutines
-keepclassmembernames class kotlinx.** { *; }
Implement workers using the AndroidWorker interface. Add @Worker(name = ...) so the KSP plugin generates AndroidWorkerFactoryGenerated automatically — no manual factory boilerplate needed.
// androidMain
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 SyncWorker : AndroidWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
return try {
// Your sync logic here
WorkerResult.Success("Sync complete")
} catch (e: Exception) {
WorkerResult.Retry(reason = "Sync failed: ${e.message}")
}
}
}
@Worker(name = "UploadWorker")
class UploadWorker : AndroidWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
return try {
// Your upload logic here
WorkerResult.Success("Upload complete")
} catch (e: Exception) {
WorkerResult.Retry(reason = "Upload failed: ${e.message}")
}
}
}The name value must match the workerClassName passed to scheduler.enqueue(...). KSP generates AndroidWorkerFactoryGenerated containing all @Worker-annotated classes — pass it to KmpWorkManager.initialize(workerFactory = AndroidWorkerFactoryGenerated()).
For tasks longer than 10 minutes, set isHeavyTask = true:
scheduler.enqueue(
id = "ml-training",
trigger = TaskTrigger.OneTime(),
workerClassName = "MLTrainingWorker",
constraints = Constraints(
isHeavyTask = true,
requiresCharging = true
)
)This uses KmpHeavyWorker which runs as a foreground service.
expedited is not a Constraints field. Expediting on Android is driven by
TaskRequest.priority instead — CRITICAL/HIGH map to setExpedited() (subject to
delay/heavy/charging/unmetered checks); this is chain-step-only (TaskRequest), since plain
enqueue() has no priority parameter:
scheduler.beginWith(
TaskRequest(workerClassName = "SyncWorker", priority = TaskPriority.CRITICAL)
).enqueue(id = "urgent-sync")Monitor MediaStore changes:
scheduler.enqueue(
id = "media-observer",
trigger = TaskTrigger.ContentUri(
uriString = "content://media/external/images/media",
triggerForDescendants = true
),
workerClassName = "MediaSyncWorker"
)Warning
Critical iOS Limitations - Read Before Implementing
iOS background tasks are fundamentally different from Android:
- Opportunistic Execution: The system decides when to run tasks. Tasks may be delayed hours or never run.
- Strict Time Limits: BGAppRefreshTask has ~30 seconds max, BGProcessingTask has ~60 seconds.
- Force-Quit Termination: All background tasks are immediately killed when user force-quits the app.
- Limited Constraints: iOS only supports network constraints. Charging, battery, and storage constraints are not available.
Do NOT use iOS background tasks for:
- Time-critical operations
- Long-running processes (> 30s)
- Operations that must complete reliably
See iOS Best Practices for detailed guidance and iOS Migration Guide for converting Android patterns to iOS.
Since v2.4.1, KMP WorkManager routes every task ID you pass to
scheduler.enqueue(...) through one of two internal dispatcher tasks. You only
need to declare those two IDs in BGTaskSchedulerPermittedIdentifiers — no
per-task entries:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Background Task Identifiers -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>kmp_master_dispatcher_task</string>
<string>kmp_chain_executor_task</string>
</array>
<!-- Background Modes -->
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
<string>fetch</string>
<string>remote-notification</string>
</array>
<!-- Disable Scene-based lifecycle (if using traditional AppDelegate) -->
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
</dict>
</plist>Optional — per-task identifiers. If you'd like a specific task ID to be scheduled directly by iOS (instead of being queued through the master dispatcher), you may add that ID to the array above and register its own handler with
handleSingleTask(see §4 below). Most apps do not need this — the master dispatcher is sufficient for arbitrary dynamic task IDs.
Open your Xcode project and verify:
-
Signing & Capabilities:
- Add "Background Modes" capability
- Enable "Background fetch" and "Background processing"
-
Build Settings:
- Set
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO(if using AppDelegate) - Ensure deployment target is iOS 13.0 or higher
- Set
-
General:
- Verify bundle identifier matches your configuration
Before initializing, you must create a worker factory:
// iosMain/background/MyWorkerFactory.kt
class MyWorkerFactory : IosWorkerFactory {
override fun createWorker(workerClassName: String): IosWorker? {
return when (workerClassName) {
"SyncWorker" -> SyncWorker()
"UploadWorker" -> UploadWorker()
"HeavyProcessingWorker" -> HeavyProcessingWorker()
else -> {
Logger.e(LogTags.FACTORY, "Unknown worker: $workerClassName")
null
}
}
}
}Register factory in your iOS module:
// iosMain/di/IOSModule.kt
val iosModule = module {
// The library is DI-agnostic — initialize it, then expose what you need.
KmpWorkManager.initialize(workerFactory = MyWorkerFactory())
single<BackgroundTaskScheduler> { KmpWorkManager.getInstance().backgroundTaskScheduler }
// Your other iOS-specific dependencies...
}Create or update iOSApp.swift:
import SwiftUI
import BackgroundTasks
import composeApp // Your shared framework name
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// No DI framework required since v3.3.0. `SetupKt` is your own Kotlin file
// exposing top-level functions to Swift — see the `composeApp` demo.
SetupKt.initKmpWorkManager()
// Register background tasks
registerBackgroundTasks()
// Request notification permissions
requestNotificationPermissions()
return true
}
private func registerBackgroundTasks() {
let scheduler = SetupKt.kmpscheduler()
let chainExecutor = SetupKt.kmpchainExecutor()
let dispatcher = SetupKt.kmpdynamicTaskDispatcher()
// 1. Master dispatcher — wakes up every dynamic task ID that is NOT
// declared as its own BGTask identifier in Info.plist. Required.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_master_dispatcher_task",
using: nil
) { task in
IosBackgroundTaskHandler.shared.handleMasterDispatcherTask(
task: task,
dispatcher: dispatcher,
scheduler: scheduler
)
}
// 2. Chain executor — batches task-chain execution. Required.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_chain_executor_task",
using: nil
) { task in
IosBackgroundTaskHandler.shared.handleChainExecutorTask(
task: task,
chainExecutor: chainExecutor
)
}
// 3. (Optional) Per-task identifiers. Only needed if you've also added
// these specific IDs to `BGTaskSchedulerPermittedIdentifiers` to let
// iOS schedule them directly instead of via the master dispatcher.
// Skip this whole block unless you have a specific reason for it.
//
// let executor = SetupKt.kmpsingleTaskExecutor()
// BGTaskScheduler.shared.register(
// forTaskWithIdentifier: "periodic-sync-task",
// using: nil
// ) { task in
// IosBackgroundTaskHandler.shared.handleSingleTask(
// task: task,
// scheduler: scheduler,
// executor: executor
// )
// }
}
private func requestNotificationPermissions() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { granted, error in
if granted {
print("Notification permission granted")
} else if let error = error {
print("Notification permission error: \(error)")
}
}
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Background tasks can now execute
print("App entered background")
}
}Create worker classes in iosMain/background/workers/:
// SyncWorker.kt
class SyncWorker : IosWorker {
override suspend fun doWork(
input: String?,
env: WorkerEnvironment
): WorkerResult {
return try {
// IMPORTANT: Must complete within 25 seconds for BGAppRefreshTask
// or within a few minutes for BGProcessingTask
Logger.i(LogTags.WORKER, "iOS SyncWorker started")
delay(2000) // Simulate work
WorkerResult.Success("✅ iOS sync complete")
} catch (e: Exception) {
Logger.e(LogTags.WORKER, "iOS sync failed", e)
WorkerResult.Failure(e.message ?: "Unknown error")
}
}
}Important: Workers must be registered in your MyWorkerFactory (see Step 3 above).
// Light task (BGAppRefreshTask - 25 seconds max)
scheduler.enqueue(
id = "quick-sync",
trigger = TaskTrigger.Periodic(15_MINUTES),
workerClassName = "SyncWorker",
constraints = Constraints(
isHeavyTask = false // Uses BGAppRefreshTask
)
)
// Heavy task (BGProcessingTask - several minutes)
scheduler.enqueue(
id = "ml-training",
trigger = TaskTrigger.OneTime(),
workerClassName = "MLTrainingWorker",
constraints = Constraints(
isHeavyTask = true // Uses BGProcessingTask
)
)Constraints.qos accepts an iOS priority hint, but is currently a no-op on both
platforms — nothing reads it to influence scheduling yet (see
docs/constraints-triggers.md). Use TaskRequest.priority/isHeavyTask for real effect:
scheduler.enqueue(
id = "high-priority-sync",
trigger = TaskTrigger.Periodic(15_MINUTES),
workerClassName = "SyncWorker",
constraints = Constraints(
qos = Qos.UserInitiated // Accepted, but currently has no effect
)
)For remote-triggered tasks, configure APNS:
- Enable "Remote notifications" in Background Modes
- Send silent push with
content-available: 1 - Handle in
didReceiveRemoteNotification:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
let scheduler = SetupKt.kmpScheduler()
// Trigger background task
Task {
await scheduler.enqueue(
id: "push-triggered-sync",
trigger: TaskTriggerOneTime(initialDelayMs: 0),
workerClassName: "SyncWorker",
input: nil,
constraints: Constraints()
)
completionHandler(.newData)
}
}# View all scheduled tasks
adb shell dumpsys jobscheduler | grep KmpWorker
# Force run a task (requires WorkManager Test helpers)
adb shell am broadcast -a androidx.work.diagnostics.REQUEST_DIAGNOSTICS
# Check WorkManager database
adb shell sqlite3 /data/data/YOUR.PACKAGE.NAME/databases/androidx.work.workdatabase "SELECT * FROM WorkSpec"# Unplug device
adb shell dumpsys battery unplug
# Enter Doze mode
adb shell dumpsys deviceidle force-idle
# Exit Doze mode
adb shell dumpsys deviceidle unforce
# Reset battery
adb shell dumpsys battery reset# Check if app can schedule exact alarms
adb shell dumpsys alarm | grep YOUR.PACKAGE.NAME
# View next alarm
adb shell dumpsys alarm | grep -A 20 "Next alarm clock"In Xcode, run the app and pause at a breakpoint, then in LLDB console:
# Force execute a task via the Master Dispatcher
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"kmp_master_dispatcher_task"]
# Force execute the Chain Executor
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"kmp_chain_executor_task"]
Add launch arguments in Xcode scheme:
- Product → Scheme → Edit Scheme
- Run → Arguments → Arguments Passed On Launch
- Add:
-BGTaskSchedulerSimulateEarlyTermination
This simulates app termination during background task execution.
# View iOS device logs
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.apple.BGTaskScheduler"' --level debug
# Or use Console.app to filter by BGTaskScheduler- Connect device to Xcode
- Run app and send to background (Home button)
- Wait or trigger via LLDB (connect debugger to running app)
- Check logs in Xcode Console
Important: BGTasks only run on physical devices when app is truly in background and system decides to execute them. Testing requires patience or LLDB simulation.
Problem: Scheduled tasks never execute
Solutions:
-
Check WorkManager initialization:
val workManager = WorkManager.getInstance(context) val workInfos = workManager.getWorkInfosForUniqueWork("task-id").get() println("Work state: ${workInfos.firstOrNull()?.state}")
-
Verify constraints are met:
adb shell dumpsys battery unplug adb shell svc wifi enable -
Check for Doze mode restrictions:
adb shell dumpsys battery unplug adb shell dumpsys deviceidle whitelist +YOUR.PACKAGE.NAME
Problem: TaskTrigger.Exact doesn't fire (Android)
Solutions:
-
Check permission:
val alarmManager = getSystemService(AlarmManager::class.java) val canSchedule = alarmManager.canScheduleExactAlarms() println("Can schedule exact alarms: $canSchedule")
-
Request permission manually:
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) startActivity(intent)
-
Verify
AlarmReceiveris registered inAndroidManifest.xml
On iOS this is not a permission bug to fix. iOS has no exact-alarm primitive at all — by default
TaskTrigger.Exactonly shows a local notification and does not run your worker code unless the user taps it. Readdocs/IOS_BGTASK_LIMITS.md§5 before assuming this is something to configure your way out of.
Problem: KmpHeavyWorker crashes with ForegroundServiceStartNotAllowedException
Solutions:
-
Add foreground service permission:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
-
Request notification permission on Android 13+
-
Create proper notification channel:
val channel = NotificationChannel( "heavy_task_channel", "Heavy Tasks", NotificationManager.IMPORTANCE_LOW ) notificationManager.createNotificationChannel(channel)
Problem: BGTasks never run on device
Solutions:
-
Verify Info.plist configuration:
BGTaskSchedulerPermittedIdentifiersmust contain bothkmp_master_dispatcher_taskandkmp_chain_executor_task- IDs are case-sensitive
-
Check AppDelegate registration: both dispatcher handlers must be registered before the first BGTask request is submitted (i.e. during
application(_:didFinishLaunchingWithOptions:)). Missing either one is the most common cause of "task never fires" — see the AppDelegate snippet in §4 above. -
App must be in background:
- Press Home button to background app
- Wait several minutes or hours (iOS decides when to run)
- Use LLDB to force execution for testing
-
Check system logs:
log stream --predicate 'subsystem == "com.apple.BGTaskScheduler"' --level debug
Problem: BGAppRefreshTask terminates after 25 seconds
Solutions:
-
Use BGProcessingTask for longer work:
constraints = Constraints(isHeavyTask = true)
-
Optimize worker to complete faster:
class SyncWorker : IosWorker { override suspend fun doWork(input: String?): Boolean { withTimeout(20_000) { // Complete within 20 seconds // Fast sync logic } return true } }
-
Split work into chains:
scheduler .beginWith(TaskRequest(workerClassName = "QuickSync1")) .then(TaskRequest(workerClassName = "QuickSync2")) .enqueue()
Problem: IosWorkerFactory returns null
Solutions:
-
Register worker in factory:
object IosWorkerFactory { fun createWorker(className: String): IosWorker? { return when (className) { "SyncWorker" -> SyncWorker() else -> null } } }
-
Check worker class name spelling (case-sensitive)
-
Verify worker implements
IosWorkerinterface
Problem: Task runs once but doesn't repeat
Solution: The library's IosBackgroundTaskHandler automatically re-schedules
periodic tasks upon successful completion.
- If you use the default dispatcher setup (recommended): periodic dynamic tasks
are re-enqueued by
DynamicTaskDispatcherafter each successful run — nothing more to do as long ashandleMasterDispatcherTaskis registered. - If you opted into a per-task identifier and registered
handleSingleTaskyourself, the same re-scheduling logic runs inside that handler. Make sure the call signature is correct:
IosBackgroundTaskHandler.shared.handleSingleTask(
task: task,
scheduler: SetupKt.kmpscheduler(),
executor: SetupKt.kmpsingleTaskExecutor()
)- Use
TaskRequest.priority = TaskPriority.CRITICAL/HIGHfor urgent chain steps (< 10 minutes) —expeditedis not a realConstraintsfield - Use
isHeavyTask = truefor long tasks (> 10 minutes) - Always handle
Result.retry()for transient failures - Request permissions before scheduling tasks
- Test in Doze mode to ensure tasks run when expected
- Keep BGAppRefreshTask workers under 20 seconds
- Use BGProcessingTask (
isHeavyTask = true) for heavy work - Use
IosBackgroundTaskHandlerto automate re-scheduling and metadata resolution - Register task handlers BEFORE scheduling tasks
- Test on physical devices (simulator behavior differs)
- Use LLDB commands for testing (don't wait hours for iOS to trigger)
- Metadata is persisted automatically in
Library/Application Support(IosFileStorage)
- Quick Start Guide - Get started quickly
- API Reference - Complete API documentation
- Task Chains - Build complex workflows
- Constraints & Triggers - All trigger types
Need help? Open an issue or ask in Discussions.