Also available on Medium
✏️ Introduction
Running reliable background tasks is one of those things that sounds simple until you do it cross-platform. If you’re building a Kotlin Multiplatform (KMP) app, and you want background syncs for fresh data, offline caching, or periodic uploads, you quickly hit a wall: there’s no common API for background work out of the box.
In this article, I’ll show you how to bridge that gap using WorkManager on Android and BGTaskScheduler on iOS with a real demo you can clone and try out: https://github.com/kmpbits/BackgroundSchedulerKMPDemo.
Let’s break it down, KMP-style.
⚙️ Why Background Sync is Tricky in KMP
Background work is a classic case of the same goal, different platforms, no shared API.
- On Android, we have WorkManager — a robust, battery-friendly way to handle deferrable background work.
- On iOS, Apple gives us BGTaskScheduler — but it has stricter system limits, user expectations, and a different lifecycle.
- In KMP, there’s no first-party BackgroundSync library that magically works for both; you have to add code for androidMain and iOSMain separately.
🤖 WorkManager on Android
What is WorkManager? WorkManager is Android Jetpack’s modern solution for scheduling background tasks that are guaranteed to run… eventually. It handles battery optimizations, Doze mode, and system restarts.
In my demo, I use WorkManager to:
- Schedule periodic syncs
- Run tasks under constraints (e.g., battery not low)
- Chain or cancel work
Here’s how the Android side works:
1: Add the WorkManager dependency to libs.version.toml
:
workmanager = "2.10.2"
[libraries]
workmanager = { module = "androidx.work:work-runtime-ktx", version.ref = "workmanager" }
2: Add it to the build.gradle.kts
file, on androidMain block:
androidMain.dependencies {
// Other dependencies...
implementation(libs.workmanager)
}
3: Create the PrintLogWorker class
class PrintLogWorker(
context: Context,
workerParameters: WorkerParameters
) : Worker(context, workerParameters) {
override fun doWork(): Result {
printLog("Android App Refresh Task")
return Result.success()
}
}
4: Create the instance of the WorkManager and enqueue the work every 15 minutes
fun initWorker(context: Context) {
val workManagerBuilder = PeriodicWorkRequestBuilder<PrintLogWorker>(
repeatInterval = Constants.TASK_TIMER_SECONDS.toLong(),
repeatIntervalTimeUnit = TimeUnit.MINUTES
)
// Optional: Set constraints for the worker
.setConstraints(
Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build()
)
// Enqueue the work every 15 minutes
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
uniqueWorkName = Constants.TASK_IDENTIFIER,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
request = workManagerBuilder.build()
)
}
5: Add the initWorker()
function to the Application’s onCreate()
method.
class ComposeApp : Application() {
override fun onCreate() {
super.onCreate()
initWorker(this)
}
}
Note: Don’t forgot to add the ComposeApp
to the Manifest
file.
🍏 BGTaskScheduler on iOS
On iOS, things are more restrictive: you have to add the background mode capability, register at launch, and you can’t schedule them too frequently, iOS decides when they run.
I use BGAppRefreshTask to refresh app data in the background.
Here’s the code for iOS:
1: Add the Background Modes capability
On XCode: iosApp > Signing & Capabilities > + button > Search: Background Modes. Then check Background fetch and Background processing
2: Create the worker in scheduleAppRefreshTask()
and register it on registerTask()
Note: Inside the registerForTaskWithIdentifier
block, you have to call the code to run on background and then call the scheduleAppRefreshTask()
again.
BGTaskScheduler doesn’t allow periodic work automatically.
fun registerTask() {
BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier(
identifier = Constants.TASK_IDENTIFIER,
usingQueue = null
) { task ->
// Call the function to do the work on background
printLog("iOS App Refresh Task")
// Then call the function to schedule the task again
scheduleAppRefreshTask()
(task as? BGAppRefreshTask)?.setTaskCompletedWithSuccess(true)
}
}
@OptIn(ExperimentalForeignApi::class)
private fun scheduleAppRefreshTask() {
val request = BGAppRefreshTaskRequest(identifier = Constants.TASK_IDENTIFIER)
request.earliestBeginDate = NSDate().dateByAddingTimeInterval((Constants.TASK_TIMER_SECONDS * 60).toDouble()) // 15 minutes
BGTaskScheduler.sharedScheduler.submitTaskRequest(request, null)
}
3: Call the registerTask()
on iOSApp.swift
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
// Other initializations...
BackgroundWorkTaskIOSKt.registerTask()
return true
}
}
💡 Gotchas & Tips
- Android: Always test with real devices — emulators handle Doze mode differently.
- iOS: BGTasks need UIApplication setup — watch out for app states and expiration handlers.
- Both: Don’t try to run syncs too often. The OS is the boss. Usually the minimum is 15 minutes.
- Test battery impact. Both platforms may throttle or kill your tasks if you overdo it.
🚀 Demo Project
I’ve put all of this into a small working demo you can fork and run. The demo app logs a message to Logcat roughly every 15 minutes.
Note: the exact timing depends on device conditions — the OS ultimately decides when to run.
📌 Repo: https://github.com/kmpbits/BackgroundSchedulerKMPDemo
Note: To test, you have to run the app on a real device.
🔮 Next Steps & Improvements
- Could we wrap this into a small KMP library? Maybe.
- I’d love to see an official KMP-friendly background sync API someday.
- If you have ideas, contributions, or improvements, open an issue in the repo!
✅ Conclusion
Background syncs are more work in cross-platform, but KMP’s androidMain and iOSMain makes it doable. Keep your shared logic clean, respect platform constraints, and test well.
If you found this useful, follow me on Medium for more practical KMP tips.