This guide covers critical iOS-specific considerations and best practices when using KMP WorkManager.
- Critical iOS Limitations
- Understanding iOS Background Tasks
- Task Design Guidelines
- Force-Quit Behavior
- Constraint Limitations
- Performance Optimization
- Testing iOS Background Tasks
- Common Pitfalls
Previously, iOS BGTaskScheduler required all task identifiers to be statically declared in your Info.plist file (BGTaskSchedulerPermittedIdentifiers).
However, starting in v2.4.1, KMP WorkManager natively supports dynamic task IDs (e.g., upload-photo-123).
You only need to declare the library's master dispatcher task (kmp_master_dispatcher_task) and chain executor (kmp_chain_executor_task) in your Info.plist, and register a handler for each in your AppDelegate (handleMasterDispatcherTask and handleChainExecutorTask respectively). The library then routes every dynamic task through the master dispatcher's internal queue and executes them when iOS fires the master dispatcher slot.
For historical context on how this works under the hood, read the iOS Dynamic Task Scheduling Guide.
iOS background tasks are fundamentally different from Android's WorkManager:
// ❌ BAD: Expecting predictable execution
scheduler.enqueue(
id = "urgent-sync",
trigger = TaskTrigger.Periodic(intervalMs = 900_000), // Every 15 minutes
workerClassName = "DataSyncWorker"
)
// Reality: iOS may run this in 1 hour, 4 hours, or neverKey Points:
- The system decides when to run tasks based on:
- Device usage patterns
- Battery level
- Thermal state
- App usage history
- Network availability
- Tasks may be delayed by hours or never execute
- No guarantees on execution timing
| Task Type | Time Limit | When Available |
|---|---|---|
BGAppRefreshTask |
~30 seconds | Always |
BGProcessingTask |
~60 seconds | Requires charging + WiFi |
// ❌ BAD: Long-running task
class ProcessDataWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
// This will timeout after 30 seconds!
processLargeDataset() // Takes 2 minutes
return WorkerResult.Success()
}
}
// ✅ GOOD: Break into chunks
class ProcessDataWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val batch = getNextBatch()
processBatch(batch) // Takes 5 seconds
if (hasMoreBatches()) {
scheduleNextBatch()
}
return WorkerResult.Success()
}
}When a user force-quits your app, ALL background tasks are immediately killed.
This is by iOS design and cannot be worked around.
// ❌ BAD: Critical data operation
class SaveUserDataWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
// If user force-quits during this, data may be corrupted!
database.beginTransaction()
database.updateUserProfile(data)
database.commit() // May never reach here
return WorkerResult.Success()
}
}
// ✅ GOOD: Use foreground operation for critical tasks
fun saveUserData() {
// Show progress UI to prevent force-quit
showSavingDialog()
database.saveInTransaction(data)
hideSavingDialog()
}iOS does not support these Android constraints:
// ❌ THESE WILL BE IGNORED ON iOS:
Constraints(
requiresCharging = true, // iOS ignores this
requiresBatteryNotLow = true, // iOS ignores this
requiresStorageNotLow = true, // iOS ignores this
systemConstraints = setOf(
SystemConstraint.REQUIRE_BATTERY_NOT_LOW, // iOS ignores
SystemConstraint.DEVICE_IDLE // iOS ignores
)
)
// ✅ ONLY THESE WORK ON iOS:
Constraints(
requiresNetwork = true, // Supported
requiresUnmeteredNetwork = true // Supported (WiFi only)
)KMP WorkManager automatically chooses the right task type:
| Your Trigger | iOS Task Type | Time Limit | Requirements |
|---|---|---|---|
Periodic |
BGAppRefreshTask |
~30s | None |
OneTime |
BGAppRefreshTask |
~30s | None |
OneTime with long duration |
BGProcessingTask |
~60s | Charging + WiFi |
// Android: Runs every 15 minutes ±5 minutes
// iOS: System decides when to run (could be hours)
scheduler.enqueue(
id = "sync",
trigger = TaskTrigger.Periodic(intervalMs = 900_000),
workerClassName = "SyncWorker"
)iOS Scheduling Factors:
- App usage: Frequently used apps get more background time
- Time patterns: System learns when user typically uses app
- Battery: Low battery reduces background activity
- Network: Tasks requiring network wait for connectivity
- Force-quit: Resets background execution privileges
// ❌ BAD: Monolithic task
class SyncAllDataWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
syncUsers() // 10s
syncPosts() // 15s
syncComments() // 20s
syncImages() // 30s
// Total: 75s - WILL TIMEOUT!
return WorkerResult.Success()
}
}
// ✅ GOOD: Modular tasks
class SyncUsersWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
syncUsers() // 10s - Safe
return WorkerResult.Success()
}
}
// Schedule separately
scheduler.enqueue(id = "sync-users", workerClassName = "SyncUsersWorker")
scheduler.enqueue(id = "sync-posts", workerClassName = "SyncPostsWorker")
scheduler.enqueue(id = "sync-comments", workerClassName = "SyncCommentsWorker")// ⚠️ WARNING: Long chains may not complete
scheduler.beginWith(TaskRequest("Step1")) // 15s
.then(TaskRequest("Step2")) // 15s
.then(TaskRequest("Step3")) // 15s
.enqueue()
// Total: 45s - HIGH RISK on BGAppRefreshTask
// ✅ BETTER: Keep chains short (2-3 steps max)
scheduler.beginWith(TaskRequest("Download")) // 10s
.then(TaskRequest("Process")) // 10s
.enqueue()
// Total: 20s - SAFEclass ResilientWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val progress = loadProgress() ?: Progress(0)
try {
withTimeout(25_000) { // Leave 5s buffer
while (!progress.isComplete()) {
val chunk = progress.nextChunk()
processChunk(chunk)
saveProgress(progress)
}
}
return WorkerResult.Success()
} catch (e: TimeoutCancellationException) {
// Save progress and reschedule
saveProgress(progress)
rescheduleTask()
return WorkerResult.Failure("Timed out — rescheduled for continuation")
}
}
}// ✅ GOOD: Critical first, optional later
class SmartSyncWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
// Critical: User-generated content (5s)
syncUserPosts()
// Important: Recent data (10s)
syncLastDayData()
// Optional: Only if time permits (15s)
withTimeoutOrNull(10_000) {
syncHistoricalData()
}
return WorkerResult.Success()
}
}- All background tasks are killed immediately
- All future tasks are canceled
- App loses background execution privileges temporarily
- EventStore persistence survives (if properly implemented)
// ❌ BAD: No force-quit protection
class PaymentWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
processPayment() // If force-quit here, payment may be lost!
return WorkerResult.Success()
}
}
// ✅ GOOD: Use foreground mode for critical operations
fun initiatePayment() {
// Show UI - prevents force-quit
showPaymentProcessingDialog()
// Process payment synchronously
processPayment()
// Then schedule background cleanup
scheduler.enqueue(
id = "cleanup-payment",
trigger = TaskTrigger.OneTime(),
workerClassName = "PaymentCleanupWorker"
)
}// ✅ GOOD: Emit events that survive force-quit
class DataSyncWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val result = syncData()
// Event persists even if app is force-quit
TaskEventManager.emit(
TaskCompletionEvent(
taskId = "data-sync",
success = result.success,
message = "Synced ${result.count} items"
)
)
return if (result.success) WorkerResult.Success(message = "Synced ${result.count} items")
else WorkerResult.Failure("Sync failed")
}
}
// On next app launch, sync events are replayed
EventSyncManager.syncEvents(eventStore)// ✅ These work on iOS:
Constraints(
requiresNetwork = true,
requiresUnmeteredNetwork = true
)// ❌ These are IGNORED on iOS:
Constraints(
requiresCharging = true, // Ignored
requiresBatteryNotLow = true, // Ignored
requiresStorageNotLow = true, // Ignored
systemConstraints = setOf(...) // Ignored
)expect fun shouldScheduleHeavyTask(): Boolean
// Android
actual fun shouldScheduleHeavyTask(): Boolean {
return true // Can rely on charging constraint
}
// iOS
actual fun shouldScheduleHeavyTask(): Boolean {
// Manually check conditions iOS can't enforce
val batteryLevel = UIDevice.currentDevice.batteryLevel
val isCharging = UIDevice.currentDevice.batteryState == .charging
return batteryLevel > 0.5 || isCharging
}// ❌ BAD: Heavy initialization
class SlowWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val database = createDatabase() // 5s
val api = initializeAPI() // 3s
doActualWork() // 8s
// Total: 16s wasted on setup
return WorkerResult.Success()
}
}
// ✅ GOOD: Reuse shared instances
class FastWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val database = SharedDatabase.instance // <1ms
val api = SharedAPI.instance // <1ms
doActualWork() // 8s
// Total: ~8s actual work
return WorkerResult.Success()
}
}// ❌ BAD: Slow I/O
suspend fun saveData(data: List<Item>) {
data.forEach { item ->
database.insert(item) // Many small writes
}
}
// ✅ GOOD: Batch operations
suspend fun saveData(data: List<Item>) {
database.insertBatch(data) // Single write
}// ❌ BAD: Sequential requests
suspend fun syncData() {
val users = api.getUsers() // 3s
val posts = api.getPosts() // 3s
val comments = api.getComments() // 3s
// Total: 9s
}
// ✅ GOOD: Parallel requests
suspend fun syncData() {
coroutineScope {
val usersDeferred = async { api.getUsers() }
val postsDeferred = async { api.getPosts() }
val commentsDeferred = async { api.getComments() }
val users = usersDeferred.await()
val posts = postsDeferred.await()
val comments = commentsDeferred.await()
}
// Total: ~3s
}# Trigger BGAppRefreshTask
xcrun simctl spawn booted \
log stream --predicate 'subsystem == "com.apple.BackgroundTasks"'
# Manually trigger your task
xcrun simctl spawn booted \
launchctl stop com.apple.BGTaskSchedulerAgent
# Schedule immediate execution
e -l objc -- \
(void)[[BGTaskScheduler sharedScheduler] \
_simulateLaunchForTaskWithIdentifier:@"your.task.id"]class TimeoutTestWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val startTime = System.currentTimeMillis()
try {
doLongRunningWork()
val duration = System.currentTimeMillis() - startTime
Logger.i("TimeoutTest", "Completed in ${duration}ms")
return WorkerResult.Success(message = "Completed in ${duration}ms")
} catch (e: TimeoutCancellationException) {
val duration = System.currentTimeMillis() - startTime
Logger.w("TimeoutTest", "Timeout after ${duration}ms")
return WorkerResult.Failure("Timeout after ${duration}ms")
}
}
}- Schedule a background task
- Background the app
- Force-quit the app (swipe up in app switcher)
- Check if task executed (it shouldn't)
- Reopen app and check EventStore for persistence
// ❌ WRONG EXPECTATION
// "My periodic task will run every 15 minutes"
// ✅ CORRECT EXPECTATION
// "My periodic task will run when iOS decides,
// which could be 15 minutes or 4 hours from now"// ❌ WILL FAIL
class VideoProcessingWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
processVideo() // Takes 5 minutes
return WorkerResult.Success()
}
}
// ✅ CORRECT APPROACH
// Process video in foreground with progress UI
fun processVideo() {
showProgressDialog()
processVideoSynchronously()
hideProgressDialog()
}// ❌ DOESN'T WORK ON iOS
scheduler.enqueue(
id = "heavy-task",
trigger = TaskTrigger.OneTime(),
workerClassName = "HeavyWorker",
constraints = Constraints(
requiresCharging = true // iOS ignores this!
)
)
// ✅ MANUAL CHECK INSTEAD
if (isCharging() || getBatteryLevel() > 0.8) {
scheduler.enqueue(
id = "heavy-task",
trigger = TaskTrigger.OneTime(),
workerClassName = "HeavyWorker"
)
}// ❌ LOSES DATA ON FORCE-QUIT
class UploadWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
uploadFile() // Lost if force-quit
deleteLocalCopy() // May execute without upload
return WorkerResult.Success()
}
}
// ✅ SAFE WITH EVENT PERSISTENCE
class UploadWorker : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val success = uploadFile()
if (success) {
TaskEventManager.emit(
TaskCompletionEvent("upload", true, "Uploaded")
)
deleteLocalCopy()
return WorkerResult.Success(message = "Uploaded")
}
return WorkerResult.Failure("Upload failed")
}
}// ❌ TOO COMPLEX FOR iOS
scheduler.beginWith(TaskRequest("Download")) // 10s
.then(TaskRequest("Validate")) // 5s
.then(TaskRequest("Transform")) // 8s
.then(TaskRequest("Process")) // 10s
.then(TaskRequest("Upload")) // 12s
.enqueue()
// Total: 45s - HIGH RISK OF TIMEOUT
// ✅ SIMPLIFIED
scheduler.beginWith(TaskRequest("DownloadAndValidate")) // 12s
.then(TaskRequest("ProcessAndUpload")) // 18s
.enqueue()
// Total: 30s - WITHIN LIMIT- Tasks complete within 25 seconds
- Critical operations use foreground mode
- Event persistence used for important state
- Only network constraints used
- Handles timeout gracefully
- Handles force-quit gracefully
- Minimal setup/initialization time
- Batch operations used where possible
- Network calls parallelized
- Tested timeout scenarios
- Tested force-quit scenarios
- Documentation mentions iOS limitations
✅ GOOD Use Cases:
- Refreshing content when app is in background
- Syncing small amounts of data
- Checking for updates
- Lightweight processing (< 20s)
❌ BAD Use Cases:
- Time-critical operations
- Operations that must complete
- Long-running processes (> 30s)
- Heavy processing
- Large file uploads/downloads
If your operation has any of these requirements, use foreground mode:
- Must complete reliably
- User-initiated action
- Longer than 25 seconds
- Critical for app functionality
- Sensitive data operations