How do you schedule reliable background sync — WorkManager on Android vs BGTaskScheduler on iOS?
Background execution on mobile is a negotiation with the OS, not a guarantee — here's how each platform's scheduler decides when your sync actually runs.
LEARN BY DOINGPut your intuition to work3 steps
How do you schedule reliable background sync — WorkManager on Android vs BGTaskScheduler on iOS?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Nobody gets a real background thread anymore
Both major platforms spent the last decade closing the loophole where an app could keep a service alive indefinitely to poll a server, upload logs, or refresh a feed. Android has Doze mode and App Standby buckets that throttle network access and defer jobs the less you've used an app recently; iOS never promised unrestricted background CPU to begin with and instead hands out short, opportunistic execution windows it predicts you'll actually use well. If you ask "how do I run this every 15 minutes forever," the honest answer on both platforms is: you don't ask the OS to run your code on a schedule, you register a unit of work and a set of constraints, and the OS decides the actual moment based on battery, network, thermal state, and usage patterns it's tracking about your specific app.
That reframing is the whole interview question. A senior mobile engineer doesn't say "WorkManager runs every 15 minutes" — they say "WorkManager guarantees the work eventually runs when the constraints are satisfied, with a minimum interval floor, but the exact timing is opaque and shouldn't be load-bearing for anything time-sensitive."
#WorkManager: the durable, constraint-driven queue
WorkManager is a wrapper around JobScheduler, an internal SQLite-backed database, an AlarmManager fallback, and (for API levels without JobScheduler) a Firebase-style scheduler — you pick none of that, you pick behavior. There are two request shapes: OneTimeWorkRequest for a single deferred task and PeriodicWorkRequest for recurring work, whose minimum interval is 15 minutes — anything shorter is silently clamped. Work survives process death and device reboot because it's persisted, not held in memory, and you attach Constraints (network type, charging state, storage-not-low, battery-not-low) so the worker doesn't even start until the device is in a state where it's worth running.
class SyncWorker(context: Context,params: WorkerParameters,) : CoroutineWorker(context, params) {override suspend fun doWork(): Result {return try {val pending = outboxDao.pendingItems()pending.forEach { item -> api.push(item) }Result.success()} catch (e: IOException) {Result.retry()} catch (e: HttpException) {if (e.code() in 500..599) Result.retry() else Result.failure()}}}fun schedulePeriodicSync(context: Context) {val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).setRequiresBatteryNotLow(true).build()val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES).setConstraints(constraints).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS).build()WorkManager.getInstance(context).enqueueUniquePeriodicWork("outbox-sync",ExistingPeriodicWorkPolicy.KEEP,request,)}
enqueueUniquePeriodicWork with a name is what actually matters here: without it, calling the scheduling function twice (once on app start, once after a settings change) produces two competing periodic jobs. ExistingPeriodicWorkPolicy.KEEP says "if this named work already exists, leave it alone," which is what you want for a sync job whose constraints haven't changed. For work that must run soon rather than eventually — a "send this message now, even in Doze" case — WorkManager 2.7+ exposes setExpedited(), which asks the OS for an expedited job (or falls back to a short-lived foreground service on older APIs) instead of queuing behind the normal scheduler.
#BGTaskScheduler: shorter windows, and the OS decides when
iOS splits background work into two request types under one scheduler. BGAppRefreshTaskRequest is for brief, "top off the cache" refreshes — seconds, not minutes. BGProcessingTaskRequest is for longer maintenance work — database cleanup, a batch upload — and can declare requiresNetworkConnectivity and requiresExternalPower. Both must be registered with BGTaskScheduler.shared.register(forTaskWithIdentifier:using:) before application(_:didFinishLaunchingWithOptions:) returns, and every identifier you register must also be listed under BGTaskSchedulerPermittedIdentifiers in Info.plist or the registration silently fails at runtime.
// Registered in application(_:didFinishLaunchingWithOptions:), before it returns.BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.acme.app.sync",using: nil) { task inhandleSync(task: task as! BGProcessingTask)}func scheduleSync() {let request = BGProcessingTaskRequest(identifier: "com.acme.app.sync")request.requiresNetworkConnectivity = truerequest.requiresExternalPower = falserequest.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)do {try BGTaskScheduler.shared.submit(request)} catch {print("Could not schedule sync: \(error)")}}func handleSync(task: BGProcessingTask) {scheduleSync() // reschedule the next run before doing any worklet operation = SyncOperation()task.expirationHandler = { operation.cancel() }operation.completionBlock = {task.setTaskCompleted(success: !operation.isCancelled)}OperationQueue().addOperation(operation)}
earliestBeginDate is a hint, not a promise — it tells the system "don't even consider this before now," but iOS uses on-device usage prediction to decide the actual moment, and in practice a BGProcessingTask might run once or twice a day for an app the user opens infrequently. The expirationHandler is not optional in any serious implementation: the OS gives you a budget and will terminate the process if you overrun it, so cancelling cleanly and calling setTaskCompleted from the expiration path is what keeps the OS willing to grant you future runs at all — abusing the budget gets an app throttled out of background execution entirely.
#Designing the sync itself around the uncertainty
Because neither scheduler gives you a guaranteed clock, the sync logic has to be idempotent and safe to run more often or less often than requested — a worker that reruns because the process died mid-execution must not double-submit the same outbox item, which usually means a local "in-flight" flag or a server-side idempotency key rather than trusting "this only runs once." For anything that truly can't wait — a chat message, a payment confirmation — the correct tool isn't a tighter background schedule at all, it's a push notification that wakes the app on the server's timeline instead of the OS's.
🧠 WorkManager vs BGTaskScheduler
0/2 · 0/2 answered1. What is the minimum interval WorkManager allows for a PeriodicWorkRequest?
2. What does BGTaskScheduler's earliestBeginDate actually guarantee?