Ten quick edits should not queue ten syncs: unique work in WorkManager, done right
Enqueue a sync every time a note is saved and a burst of edits queues a burst of uploads. WorkManager's unique work, the right ExistingWorkPolicy, and retry classification that returns retry() only for transient errors keep an offline-first app from syncing against itself.

The user saves a note on the subway and puts the phone in their pocket. Android may kill your process a minute later. The note still has to reach the server when the train comes up for air, maybe in an hour, maybe tomorrow. That is persistent work, and on Android persistent work means WorkManager. Google's offline-first guide says as much: draining an offline queue is persistent work that is often delegated to WorkManager.
WorkManager 2.12.0 is the stable release at the time of writing (October 2026). It stores every request in its own database, so scheduled work survives process death and reboots. The scheduling part is easy. The part that goes wrong is how many syncs you schedule, and what the worker says when one fails.
The trap: one sync per save
The obvious design is to enqueue a SyncWorker from the repository every time a row is written. It works in the emulator. Then a user edits a note ten times in a minute, and ten syncs are queued. Each one pushes the same dirty rows, each one pulls the same pages, and two of them can run at the same time against the same database. Add a retry on a flaky connection and the queue grows faster than it drains.
The fix is three decisions, all of them one line each: make the push unique work, pick the right policy for duplicates, and return Result.retry() only for errors that can actually clear up on their own.
Fix 1: unique work with the right policy
enqueueUniqueWork (one-time) and enqueueUniquePeriodicWork (periodic) take a name and a policy for what to do when unfinished work with that name already exists. For one-time work, ExistingWorkPolicy has four options:
- REPLACE cancels the existing work and enqueues the new request.
- KEEP ignores the new request and keeps the existing one.
- APPEND adds the new work to the end of the existing chain.
- APPEND_OR_REPLACE appends unless the existing chain has failed or been cancelled, in which case it starts fresh.
For a push, KEEP is the right one. If a sync is already queued, it will pick up every dirty row when it runs, including the one just saved, so the new request adds nothing.
object SyncScheduler {
private const val PUSH = "notes-push"
private const val PERIODIC = "notes-periodic"
fun requestPush(context: Context, wifiOnly: Boolean) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(syncConstraints(wifiOnly))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.KEEP, request)
}
fun schedulePeriodic(context: Context, wifiOnly: Boolean) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(syncConstraints(wifiOnly))
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
Call schedulePeriodic from Application.onCreate(); with KEEP it is harmless to call on every launch. Periodic work has a floor: the minimum repeat interval is 15 minutes, the same as JobScheduler. A flex interval lets it run within a window at the end of each period rather than at an exact moment.
Fix 2: constraints that match the user's settings
Constraints make WorkManager wait for the right conditions. For sync, a connected network is usually enough, plus the user's Wi-Fi-only setting:
fun syncConstraints(wifiOnly: Boolean) = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
One consequence is easy to miss: if a constraint stops being met while the worker runs, WorkManager stops the worker and retries it later. Your doWork must be safe to stop at any suspension point, which is one more reason every sync step should be idempotent.
Fix 3: retry only what can succeed later
When a worker returns Result.retry(), WorkManager waits for the backoff delay and runs it again. Result.failure() stops it, and fails everything chained after it. Deciding which errors are transient (timeouts, HTTP 5xx, 429) and which are permanent (401 after a token refresh, 400 for a malformed payload) is the most important line of sync code you will write:
class SyncWorker(
context: Context,
params: WorkerParameters,
private val sync: SyncEngine,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = when (sync.syncOnce()) {
SyncOutcome.Done -> Result.success()
SyncOutcome.Transient -> if (runAttemptCount < 5) Result.retry() else Result.failure()
SyncOutcome.Permanent -> Result.failure()
}
}
The backoff numbers come from the WorkManager guide: the delay can be no less than 10 seconds, the policy is LINEAR or EXPONENTIAL, and the default is EXPONENTIAL with a 30-second delay. That is the exponential backoff the offline-first guide recommends for network reads and queued writes, for free: your code only classifies the failure and returns retry(), with no sleep loop of its own. Cap attempts with runAttemptCount, as above, so a permanently broken request does not retry forever.
A worker with constructor parameters beyond context and params needs a WorkerFactory. With Hilt, annotate it with @HiltWorker and use HiltWorkerFactory; without DI, register a custom factory through Configuration.Provider in your Application and disable the default initializer in the manifest.
Chains, expedited work and the foreground rules
Work can run in sequence, each step's output becoming the next step's input. A chain is the natural shape for "upload attachments, then push notes, then pull":
WorkManager.getInstance(context)
.beginUniqueWork("full-sync", ExistingWorkPolicy.KEEP, uploadAttachments)
.then(pushNotes)
.then(pullNotes)
.enqueue()
The chaining guide's failure rule: when a step fails or is cancelled, every dependent step is marked failed or cancelled too. Keep chains short and make each step meaningful on its own, so a failure tells you something.
When the user is waiting (they tapped "Sync now"), mark the request expedited with setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST), so an exhausted quota degrades to normal work instead of dropping it. On Android versions before 12, WorkManager may run expedited work as a foreground service, so any worker used with setExpedited must implement getForegroundInfo(); leaving it out can crash on older platform versions.
For a long first sync, a CoroutineWorker can call setForeground() to run as a foreground service with an ongoing notification. If your app targets Android 14 (API 34) or higher, you must specify a foreground service type for every long-running worker, declare it in the manifest by merging it into WorkManager's own service, and request the matching permission (FOREGROUND_SERVICE_DATA_SYNC for sync). Starting with Android 16, the guide notes, long-running workers can exhaust your app's job quota. For most apps the honest design is a sync that does a bounded amount of work per run and lets the next run continue, rather than one heroic foreground session.
Show the state from the repository, not the worker
To display "Syncing..." or "Last sync failed", observe the unique work's state as a Flow with getWorkInfosForUniqueWorkFlow("notes-push") and map RUNNING, ENQUEUED and FAILED to UI states. The worker itself never touches the UI.
Checklist
- The push is unique work named once, enqueued with
ExistingWorkPolicy.KEEP. - Periodic sync is unique too, scheduled from
Application.onCreate(), interval 15 minutes or more. - Constraints read the user's Wi-Fi-only setting;
doWorkis safe to stop at any suspension point. retry()only for timeouts, 5xx and 429;failure()for 400 and 401 after a refresh.- Attempts capped with
runAttemptCount; backoff left to WorkManager. - Chains short; a failed step fails its dependents, and that is intended.
- Expedited workers implement
getForegroundInfo(); long-running workers declare a foreground service type when targeting API 34+. - Every sync step is idempotent, because a stopped worker runs it again.
A sync that outlives the app is not the clever part. The clever part is making sure only one of them runs, and that it gives up at the right moment.
