Your app needs to sync data in the background, but handling it yourself is a nightmare. Android kills your process, Doze mode blocks you, and users complain about battery drain.
WorkManager is Google's solution—and it's powerful enough to handle almost any background task reliably.
The Problem: Background Work is Hard
Think about what "background work" actually means:
- User closes app → work continues
- Phone reboots → work survives
- Network offline → work waits
- Low battery → work respects battery saver
- App updated → work continues
Doing this manually means fighting Android's lifecycle, Doze mode, and battery optimizations. WorkManager abstracts all of this.
WorkManager Basics
Dependency
// build.gradle.kts
dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.hilt:hilt-work:1.1.0")
ksp("androidx.hilt:hilt-compiler:1.1.0")
}Simple Worker
class SyncDataWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
// Your background work here
syncDataToServer()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
private suspend fun syncDataToServer() {
// Actual sync logic
}
}Enqueue Work
val workRequest = OneTimeWorkRequestBuilder<SyncDataWorker>()
.build()
WorkManager.getInstance(context)
.enqueue(workRequest)OneTime vs Periodic Work
OneTimeWorkRequest
For one-off tasks that need to happen exactly once:
// User tapped "Sync Now"
val syncRequest = OneTimeWorkRequestBuilder<SyncDataWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"sync_data",
ExistingWorkPolicy.REPLACE,
syncRequest
)PeriodicWorkRequest
For recurring tasks (minimum 15 minutes):
// Sync every hour
val periodicSync = PeriodicWorkRequestBuilder<SyncDataWorker>(
1, TimeUnit.HOURS, // repeatInterval
15, TimeUnit.MINUTES // flexInterval
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"periodic_sync",
ExistingPeriodicWorkPolicy.KEEP,
periodicSync
)Constraints: Control When Work Runs
Constraints are powerful. They're your safety net:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // Network required
.setRequiresBatteryNotLow(true) // Battery > 20%
.setRequiresCharging(true) // Must be charging
.setRequiresDeviceIdle(true) // User not using device
.setRequiresStorageNotLow(true) // Storage > 20%
.build()Common Constraint Patterns (For Beginners)
Here's when to use each constraint:
📡 For Data Sync (uploading/downloading):
- Use
NetworkType.CONNECTED- just needs any internet - Use
NetworkType.UNMETERED- needs WiFi (saves mobile data)
🔋 For Backup (when phone is safe):
- Use
setRequiresCharging(true)- won't drain battery - Use
setRequiredNetworkType(UNMETERED)- uses WiFi, not mobile data
📊 For Analytics (light background work):
- Use
setRequiresBatteryNotLow(true)- only runs when battery is healthy
🖼️ For Image Processing (heavy work):
- Use
setRequiresDeviceIdle(true)- only when user isn't using phone
Chaining Work: Sequential & Parallel
Sequential Chain
// Step 1 → Step 2 → Step 3
val workChain = workManager
.beginWith(Step1Worker())
.then(Step2Worker())
.then(Step3Worker())
.enqueue()Real example:
WorkManager.getInstance(context)
.beginWith(downloadWork)
.then(processWork)
.then(uploadWork)
.enqueue()Parallel Execution
// All three run in parallel, then combine
val parallelWork = mutableListOf<OneTimeWorkRequest>()
parallelWork.add(WorkerA())
parallelWork.add(WorkerB())
parallelWork.add(WorkerC())
WorkManager.getInstance(context)
.enqueue(parallelWork)Input/Output Data: Pass Data Between Workers
Passing Data
// Worker 1: Input → Output
class DownloadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val url = inputData.getString(KEY_URL)!!
// Do download
val localPath = downloadFile(url)
// Return output
val outputData = workDataOf(
KEY_LOCAL_PATH to localPath
)
return Result.success(outputData)
}
companion object {
const val KEY_URL = "url"
const val KEY_LOCAL_PATH = "local_path"
}
}
// Enqueue with input
val downloadWork = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(workDataOf(DownloadWorker.KEY_URL to "https://..."))
.build()
// Next worker receives output
class ProcessWorker(...) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val localPath = inputData.getString(DownloadWorker.KEY_LOCAL_PATH)!!
// Process downloaded file
}
}Retry Policies
WorkManager retries failed work automatically with exponential backoff:
val retryWork = OneTimeWorkRequestBuilder<SyncDataWorker>()
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL, // or LINEAR
30, TimeUnit.SECONDS, // initial delay
TimeUnit.SECONDS // time unit
)
.build()Retry Conditions
override suspend fun doWork(): Result {
return when {
// Retryable error
isNetworkError -> Result.retry()
// Non-retryable - permanent failure
isAuthError -> Result.failure()
// Success
else -> Result.success()
}
}Real-World Pattern: ClipVault Sync
Here's how ClipVault uses WorkManager for clipboard sync:
class ClipboardSyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
private val clipRepository: ClipRepository by lazy {
// Get repository via manual injection or Hilt
}
override suspend fun doWork(): Result {
return try {
// Get unsynced clips
val unsyncedClips = clipRepository.getUnsyncedClips()
// Upload to cloud (if Pro user)
if (isProUser()) {
unsyncedClips.forEach { clip ->
cloudApi.upload(clip)
clipRepository.markAsSynced(clip.id)
}
}
// Download new clips from cloud
val cloudClips = cloudApi.download()
clipRepository.insertAll(cloudClips)
Result.success(
workDataOf(
KEY_UPLOADED to unsyncedClips.size,
KEY_DOWNLOADED to cloudClips.size
)
)
} catch (e: NetworkException) {
Result.retry()
} catch (e: AuthException) {
Result.failure()
}
}
companion object {
const val KEY_UPLOADED = "uploaded_count"
const val KEY_DOWNLOADED = "downloaded_count"
}
}Scheduling
fun schedulePeriodSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val syncRequest = PeriodicWorkRequestBuilder<ClipboardSyncWorker>(
1, TimeUnit.HOURS,
15, TimeUnit.MINUTES
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"clipboard_sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}Testing WorkManager
Test Synchronously
@Test
fun testWorker() {
val context = ApplicationProvider.getApplicationContext<Context>()
val worker = TestListenableWorkerBuilder<SyncDataWorker>(context)
.setInputData(workDataOf("test_key" to "test_value"))
.build()
// Run synchronously
val result = worker.doWork()
assertEquals(Result.success(), result)
}Test with WorkManager Test
@Test
fun testChain() {
val workManager = WorkManager.getInstance(context)
val input = workDataOf("input" to "test")
workManager
.beginWith(Worker1())
.then(Worker2())
.enqueue()
// Assert the work chain was created
val workInfos = workManager.getWorkInfosForUniqueWork("chain").get()
assertEquals(2, workInfos.size)
}When to Use WorkManager (And When NOT To)
✅ Use WorkManager When:
Here's exactly when WorkManager shines:
🔄 Periodic Sync
- Hourly or daily data sync
- Backing up user data to cloud
- Syncing clips across devices
📤 Background Upload
- Uploading photos or files
- Sending analytics data
- Syncing database changes
🖼️ Image Processing
- Compressing images in background
- Resizing photos
- Applying filters
⏰ Scheduled Notifications
- Reminder notifications
- Daily summary notifications
🔁 Retry Failed Requests
- Network calls that might fail
- API requests that need to succeed
✅ WorkManager Pros
What makes WorkManager great:
🛡️ Survives Process Death
- Even if the user closes your app or their phone restarts, the work still completes
🔋 Respects Battery
- WorkManager batches work to save battery
- It waits for optimal conditions (charging, WiFi)
⚙️ Constraints
- You can say "only run when connected to WiFi"
- You can say "only run when battery is above 20%"
- You can say "only run when device is charging"
🔗 Chaining
- Run task A, then B, then C in order
- Run multiple tasks in parallel and wait for all
✅ Guaranteed Execution
- If it fails, it retries automatically
- It won't give up until it succeeds (or hits retry limit)
❌ Don't Use WorkManager When:
When NOT to use WorkManager:
⚡ Immediate Tasks
- When user taps a button and expects instant result
- Use regular coroutines in ViewModel instead
📱 In-App Operations
- Loading data while user is looking at screen
- Use Flow and StateFlow instead
🚨 Real-Time Needs
- Chat messages that need instant delivery
- Use Firebase Cloud Messaging (FCM) or WebSocket
👤 User-Initiated Actions
- When user explicitly triggers an action
- Just use
viewModelScope.launch { }
⏱️ Quick Tasks (< 1 minute)
- For very short tasks, coroutines are simpler
❌ WorkManager Cons
What to watch out for:
⏰ Minimum 15 Minutes
- Periodic work can't run more often than every 15 minutes
- It's not for real-time needs
📦 APK Size
- Adds roughly 100KB to your app
🧪 Testing
- Needs instrumentation tests (slower to run)
- Can't just run as unit tests
🔒 Limited Control
- Can't easily cancel work mid-execution
- Can't pause or resume
Best Practices
-
Use CoroutineWorker - Handles lifecycle automatically, easy to write async code
-
Always set constraints - Don't run expensive work when battery is low
-
Use unique work names - Prevents duplicate work:
// Good: Replaces existing work
enqueueUniqueWork("sync", ExistingWorkPolicy.REPLACE, work)
// Bad: Might run multiple times
enqueue(work)-
Keep workers small - Each worker should do one thing
-
Pass data via input/output - Don't use shared state
-
Handle retries carefully - Distinguish retryable vs non-retryable errors
-
Use Hilt integration - Workers can be Hilt-enabled:
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val repository: ClipRepository
) : CoroutineWorker(context, params) { ... }Quick Decision Guide (For Beginners)
Just getting started? Here's your cheat sheet:
🎯 What type of work?
- Need it to run once →
OneTimeWorkRequestBuilder - Need it to run regularly →
PeriodicWorkRequestBuilder
🔋 What conditions?
- Only with WiFi →
setRequiredNetworkType(NetworkType.UNMETERED) - Only when charging →
setRequiresCharging(true) - Only when battery is good →
setRequiresBatteryNotLow(true) - Only when user isn't using phone →
setRequiresDeviceIdle(true)
🔗 How to run multiple?
- Run A, then B →
beginWith(A).then(B) - Run all at once →
enqueue(listOf(A, B, C))
↩️ What happens on failure?
- Try again later →
Result.retry() - Give up permanently →
Result.failure() - Return data →
Result.success(workDataOf(...))
Key Takeaway
WorkManager handles all the hard stuff—Doze mode, battery optimization, process death—so you can focus on business logic. Always use constraints to respect user battery, and use unique work names to prevent duplicates. For most apps, a periodic hourly sync with network + battery constraints is the sweet spot.