Android WorkManager — Background Sync That Actually Works

You need your app to sync data in the background. Maybe it's uploading user activity, refreshing a feed, or sending a queued message that failed earlier. The old answer was SyncAdapter — a heavyweight framework that required a fake account, a stub ContentProvider, and a lot of boilerplate just to run a background task. Nobody misses it.

The modern answer is WorkManager. It handles scheduling, retries, constraints (like "only run on Wi-Fi"), and survives app restarts and device reboots. It's the right tool for any background work that must complete eventually — even if the user closes the app or the device restarts. This guide covers everything from a basic worker to chained tasks and observing results in your UI.

Android WorkManager background sync

WorkManager — the modern replacement for SyncAdapter, AlarmManager, and JobScheduler

When to Use WorkManager

WorkManager is not for everything. Here's how to decide:

Use Case Right Tool
Sync data that must complete even if app is closed ✅ WorkManager
Upload logs, analytics, crash reports in background ✅ WorkManager
Periodic data refresh (every 15+ minutes) ✅ WorkManager PeriodicWorkRequest
Fetch data while user is actively using the app ✅ Retrofit + Coroutines
Real-time updates (chat, live scores) ✅ WebSockets / Firebase
Push notifications ✅ FCM (Firebase Cloud Messaging)

Setup

Add WorkManager to your build.gradle (app):

dependencies {
    val work_version = "2.9.0"
    implementation "androidx.work:work-runtime-ktx:$work_version"
}

Step 1 — Create a Worker

A CoroutineWorker is the Kotlin-friendly version of WorkManager's worker class. Override doWork() — everything you put in there runs on a background thread automatically:

import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf

class SyncWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            // Get input data passed from the WorkRequest
            val userId = inputData.getString("userId") ?: return Result.failure()

            // Do your background work here — runs off the main thread
            val syncResult = syncUserData(userId)

            if (syncResult.isSuccess) {
                // Pass output data back to the observer
                val output = workDataOf("syncedCount" to syncResult.count)
                Result.success(output)
            } else {
                // Retry later — WorkManager will reschedule
                Result.retry()
            }
        } catch (e: Exception) {
            // Permanent failure — won't retry
            Result.failure()
        }
    }

    private suspend fun syncUserData(userId: String): SyncResult {
        // Your actual sync logic here — API call, DB write, etc.
        return SyncResult(isSuccess = true, count = 42)
    }
}

data class SyncResult(val isSuccess: Boolean, val count: Int)
💡 Result types:
  • Result.success() — work completed, don't retry
  • Result.retry() — something went wrong, reschedule with backoff
  • Result.failure() — permanent failure, don't retry

Step 2 — Schedule a One-Time Sync

import androidx.work.*
import java.util.concurrent.TimeUnit

// Build input data to pass to the Worker
val inputData = workDataOf("userId" to "user_123")

// Define constraints — only run on network, not low battery
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

// Create the work request
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setInputData(inputData)
    .setConstraints(constraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,  // Wait longer between each retry
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("sync_work")  // Tag for easy cancellation
    .build()

// Enqueue — WorkManager handles everything from here
WorkManager.getInstance(context).enqueue(syncRequest)

Step 3 — Schedule Periodic Background Sync

For recurring sync — like refreshing a feed every hour — use PeriodicWorkRequest. The minimum interval is 15 minutes (Android OS enforces this):

val periodicSyncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
    repeatInterval = 1,
    repeatIntervalTimeUnit = TimeUnit.HOURS
)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .addTag("periodic_sync")
    .build()

// Use KEEP to avoid scheduling duplicates if already enqueued
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "periodic_data_sync",           // Unique name — prevents duplicates
    ExistingPeriodicWorkPolicy.KEEP, // Keep existing if already scheduled
    periodicSyncRequest
)

Step 4 — Observe Work Status in Your ViewModel

WorkManager exposes a LiveData of WorkInfo so you can observe work status and update your UI when the sync completes:

class SyncViewModel(application: Application) : AndroidViewModel(application) {

    private val workManager = WorkManager.getInstance(application)

    // Observe all work tagged "sync_work"
    val syncWorkInfo: LiveData<List<WorkInfo>> =
        workManager.getWorkInfosByTagLiveData("sync_work")

    fun startSync(userId: String) {
        val inputData = workDataOf("userId" to userId)
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        val request = OneTimeWorkRequestBuilder<SyncWorker>()
            .setInputData(inputData)
            .setConstraints(constraints)
            .addTag("sync_work")
            .build()

        workManager.enqueue(request)
    }

    fun cancelSync() {
        workManager.cancelAllWorkByTag("sync_work")
    }
}
// In your Fragment
class SyncFragment : Fragment() {

    private val viewModel: SyncViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel.syncWorkInfo.observe(viewLifecycleOwner) { workInfoList ->
            val workInfo = workInfoList.firstOrNull() ?: return@observe

            when (workInfo.state) {
                WorkInfo.State.ENQUEUED  -> showStatus("Sync scheduled...")
                WorkInfo.State.RUNNING   -> showStatus("Syncing...")
                WorkInfo.State.SUCCEEDED -> {
                    val count = workInfo.outputData.getInt("syncedCount", 0)
                    showStatus("Synced $count items ✓")
                }
                WorkInfo.State.FAILED    -> showStatus("Sync failed")
                WorkInfo.State.CANCELLED -> showStatus("Sync cancelled")
                WorkInfo.State.BLOCKED   -> showStatus("Waiting for constraints...")
            }
        }

        binding.btnSync.setOnClickListener {
            viewModel.startSync("user_123")
        }
    }
}

Chaining Workers — Run Tasks in Sequence

One of WorkManager's most powerful features is chaining. You can run workers sequentially where output from one is input to the next, or in parallel:

// Sequential chain — each worker runs after the previous succeeds
WorkManager.getInstance(context)
    .beginWith(OneTimeWorkRequestBuilder<FetchDataWorker>().build())
    .then(OneTimeWorkRequestBuilder<ProcessDataWorker>().build())
    .then(OneTimeWorkRequestBuilder<UploadReportWorker>().build())
    .enqueue()

// Parallel then merge — A and B run simultaneously, C runs after both finish
val workA = OneTimeWorkRequestBuilder<FetchUserWorker>().build()
val workB = OneTimeWorkRequestBuilder<FetchSettingsWorker>().build()
val workC = OneTimeWorkRequestBuilder<MergeAndSyncWorker>().build()

WorkManager.getInstance(context)
    .beginWith(listOf(workA, workB))
    .then(workC)
    .enqueue()

Best Practices

  • Always use enqueueUniqueWork for periodic tasks — without a unique name, every app launch schedules a new instance and you end up with dozens of duplicate sync jobs running simultaneously.
  • Use EXPONENTIAL backoff for network tasks — if the server is down, exponential backoff prevents your app from hammering it with retries. Start small and double the wait time each attempt.
  • Set network constraints — always add setRequiredNetworkType(NetworkType.CONNECTED) for any sync work. WorkManager queues the work and runs it automatically when connectivity is restored.
  • Use CoroutineWorker over WorkerCoroutineWorker runs doWork() on a background dispatcher automatically. The older Worker class runs on the main thread by default which can cause ANRs.
  • Keep doWork() idempotent — WorkManager may run your worker more than once due to retries. Make sure running it twice doesn't cause duplicate data or side effects.
  • Tag your work requests — tags make it easy to observe, cancel, or query specific work without keeping a reference to the work ID.

Frequently Asked Questions

What replaced SyncAdapter in Android?
WorkManager is the modern replacement for background sync tasks that must complete even if the app is closed. For foreground data fetching while the user is active, Retrofit with Kotlin Coroutines is the recommended approach.

What is the minimum interval for PeriodicWorkRequest?
The minimum repeat interval is 15 minutes — enforced by the Android OS to preserve battery life. For more frequent updates, use a foreground service or real-time solutions like WebSockets or Firebase.

Does WorkManager survive device reboot?
Yes — WorkManager persists scheduled work in a local database. If the device restarts, WorkManager reschedules all pending work automatically.

What is the difference between OneTimeWorkRequest and PeriodicWorkRequest?
OneTimeWorkRequest runs once and completes. PeriodicWorkRequest runs repeatedly at a defined interval (minimum 15 minutes). Use OneTimeWorkRequest for triggered tasks like file uploads. Use PeriodicWorkRequest for recurring sync like refreshing a feed.

📝 Summary
  • WorkManager replaces SyncAdapter, AlarmManager, and JobScheduler for guaranteed background work
  • Use CoroutineWorker over Worker — runs on background thread automatically
  • Return Result.success(), Result.retry(), or Result.failure() from doWork()
  • Use enqueueUniquePeriodicWork to prevent duplicate sync jobs
  • Minimum periodic interval is 15 minutes — OS enforced
  • WorkManager survives device reboot — work is persisted in local DB
  • Use constraints — network, battery, storage — to run work only when safe
  • Keep doWork() idempotent — it may run more than once due to retries

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

Post a Comment

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form