Pavan Rangani

HomeBlogDeferred Background Work: WorkManager and BGTaskScheduler Compared

Deferred Background Work: WorkManager and BGTaskScheduler Compared

By Pavan Rangani · July 17, 2026 · Mobile Development

Deferred Background Work: WorkManager and BGTaskScheduler Compared

Two Platforms, Two Opposite Promises About Mobile Background Work

The single most expensive misconception in cross-platform development is that mobile background work means the same thing on Android and iOS. It does not, and the gap is not a matter of API ergonomics — the platforms make fundamentally different promises.

Android’s WorkManager offers a guarantee: work you enqueue will execute, eventually, even if the user force-quits the app or the device reboots. It persists to a database and survives process death. iOS offers no such thing. BGTaskScheduler is a request, and iOS decides — based on battery, thermal state, how often the user opens your app, and whether they are on wifi — whether to grant it. It may run in ten minutes, tomorrow, or never.

Design for the weaker promise. A sync built on the assumption that background work happens is a sync that silently rots on iOS, and the bug reports will say “my data was out of date” rather than anything actionable.

WorkManager: Constraints Do the Thinking

WorkManager’s real value is that you describe conditions rather than write scheduling logic. You do not check for wifi, poll for charging state, or implement your own retry loop; you declare requirements and the system runs your work when they hold.

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)   // wifi, not the user's data
    .setRequiresBatteryNotLow(true)
    .setRequiresStorageNotLow(true)
    .build()

val upload = OneTimeWorkRequestBuilder()
    .setConstraints(constraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,   // 10s floor, doubling each retry
        TimeUnit.MILLISECONDS,
    )
    .setInputData(workDataOf("batchId" to batchId))
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "upload-$batchId",
    ExistingWorkPolicy.KEEP,   // don't stack duplicates if called twice
    upload,
)

enqueueUniqueWork with KEEP deserves attention. Without it, a screen that enqueues on every resume will happily queue the same upload fifteen times, and you will find out from your server’s logs. Unique work with an explicit collision policy is almost always what you want.

Return Values Are the Contract

What a Worker returns decides whether it runs again, and getting this wrong produces either infinite retries or silent data loss.

class UploadWorker(ctx: Context, params: WorkerParameters)
    : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result {
        val batchId = inputData.getString("batchId")
            ?: return Result.failure()      // malformed input: never retry

        return try {
            api.upload(repo.pending(batchId))
            Result.success()
        } catch (e: IOException) {
            // Transient: network blip, timeout. Retry with backoff.
            if (runAttemptCount >= 5) Result.failure() else Result.retry()
        } catch (e: HttpException) {
            // 4xx is our bug — retrying just burns battery to fail again.
            if (e.code() in 500..599) Result.retry() else Result.failure()
        }
    }
}

The distinction that matters: Result.retry() for anything transient, Result.failure() for anything a retry cannot fix. Retrying a 400 forever is a battery drain with a guaranteed failure at the end. And cap runAttemptCount yourself — exponential backoff without a ceiling means work that limps on for days.

Workers must also be idempotent, and this is not optional. WorkManager can and will run your worker more than once: the process is killed mid-upload, the result never records, and it retries. If your worker is not safe to run twice, you will get duplicate records — which is why an idempotency key on the server is the real fix, exactly as covered in our idempotency keys and exactly-once API design guide. The client cannot solve this alone.

Smartphone screen showing an app performing mobile background work
WorkManager persists work to disk, so it survives force-quit and reboot — iOS makes no such promise.

Deferred Is Not Immediate

WorkManager is the wrong tool for anything the user is waiting on. Its minimum periodic interval is 15 minutes, and even one-time work is subject to Doze, app standby buckets, and batching — a “run now” request can be deferred for a long while on a device the user has not touched.

If the user is watching, that is a foreground service, and Android has tightened the rules around those considerably. Our Android foreground services restrictions guide covers where that line sits now. Use WorkManager for work that should happen but nobody is staring at: log upload, cache warming, deferred sync.

BGTaskScheduler: Asking Nicely

iOS inverts the model. You register a task identifier at launch, submit a request with an earliest-begin date, and then wait to see whether the system agrees.

// Info.plist must list the identifier under BGTaskSchedulerPermittedIdentifiers,
// and registration must happen before application(_:didFinishLaunchingWithOptions:) returns.
BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.example.app.refresh",
    using: nil
) { task in
    handleRefresh(task as! BGAppRefreshTask)
}

func scheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)  // a hint, not a schedule
    try? BGTaskScheduler.shared.submit(request)
}

func handleRefresh(_ task: BGAppRefreshTask) {
    scheduleRefresh()   // reschedule FIRST — you get no second chance

    let op = SyncOperation()
    task.expirationHandler = {
        // Seconds of warning before the system kills you. Save partial state.
        op.cancel()
    }
    op.completionBlock = { task.setTaskCompleted(success: !op.isCancelled) }
    queue.addOperation(op)
}

Three rules, all of which bite people. Reschedule at the start of the handler, because iOS only holds one pending request per identifier and forgetting means your task never runs again. Always call setTaskCompleted — miss it and the system counts your app as misbehaving and grants you less time in future. And treat expirationHandler as a real code path, not a formality: you get very little warning, and work that cannot checkpoint will lose whatever it had.

earliestBeginDate is the honest name for what it is — the earliest the system will consider you, not when you will run. Set it and expect nothing.

Mobile app interface illustrating deferred sync and background refresh
BGTaskScheduler’s earliestBeginDate is a hint — iOS decides whether your task runs at all.

The Budget Nobody Reads About

Here is what surprises people most: iOS allocates background time based on user behaviour. An app someone opens daily gets background opportunities. An app opened once a month gets almost none, and an app the user has force-quit from the app switcher gets nothing at all until they open it again — force-quit on iOS is a signal, and the system honours it.

This has a design consequence that no API tuning will fix. Your least engaged users — precisely the ones whose data is most stale — are the ones whose background work runs least. You cannot solve engagement with a scheduler.

Testing makes this worse, because the simulator will not reproduce it. Use the debugger to force execution rather than waiting and concluding it works:

# Pause in LLDB, then force your task to launch immediately
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] \
  _simulateLaunchForTaskWithIdentifier:@"com.example.app.refresh"]

# Android: WorkManager has a proper testing story
adb shell cmd jobscheduler run -f com.example.app 999
Several phones on a desk used to test background task scheduling
Force background tasks in the debugger — waiting for the scheduler proves nothing.

Designing for Both Without Lying to Yourself

The workable strategy is to treat background execution as an optimisation, never as the mechanism. Sync opportunistically on foreground — that is the only moment you are actually guaranteed — and let background work merely reduce how much is left to do. If your feature requires background execution to be correct, it is broken on iOS and you have not found out yet.

For genuinely time-sensitive delivery, push is the answer on both platforms, not polling. Silent push on iOS gets you background time that BGTaskScheduler will not, though it too is rate-limited and unreliable enough that it cannot be your only path. And keep the scheduling logic at the platform edge with shared sync logic behind it: the boundary in our Kotlin Multiplatform shared ViewModel guide works well here, since the what is shareable even though the when is emphatically not. Apple’s BackgroundTasks documentation is worth reading closely for the constraints it states plainly.

Reliable mobile background work starts with accepting that the two platforms are not symmetrical. WorkManager guarantees eventual execution, so use constraints instead of hand-rolled polling, return retry only for transient failures, cap your attempts, and make every worker idempotent because it will run twice. BGTaskScheduler guarantees nothing, so reschedule first, always call setTaskCompleted, handle expiration as a real path, and accept that a disengaged user’s app simply will not run. Build the sync so foreground is the contract and background is a bonus — then it degrades gracefully on iOS instead of failing invisibly.

← Back to all articles