Skip to content
All essays

Mobile architecture

Background sync on iOS and Android: what you can promise

A product manager asks for sync every fifteen minutes in the background. Neither platform will give it to you, and the honest answer is not "we can't". It is a different promise: work the user did is never lost, data is current when the app opens, and the screen says how old it is. What iOS and Android actually guarantee, checked against their documentation, and a design that survives process death and deferral.

14 min read

The request usually sounds reasonable: "keep the data fresh in the background, say every fifteen minutes". The engineer who agrees has just promised something no mobile platform sells. The engineer who says "background sync is unreliable" is right and unhelpful. Neither answer gives the product a promise it can keep.

I have run 500+ technical interviews from the hiring side over 15 years in mobile, and this is where design rounds and design reviews separate. Weak answers name the API. Strong answers name the guarantee, and the promise built on it. On both platforms, background time is a budget the system grants, not a schedule you set. Design so that correctness never depends on that budget, and spend it only to make data fresher.

This page covers the platform side of a sync design. Choosing the engine itself is in how to choose a sync solution, and the full interview round, with process death and WorkManager carrying the score, is in the Android system design interview. The architecture below comes from my book Controlled Change.

  • What each platform guarantees, with links to the documentation that says so.
  • What neither platform guarantees, and the promise you can make anyway.
  • A design that survives process death, deferral and a user who never opens the app.

What iOS gives you: opportunities, not a queue

iOS has no persistent job queue that belongs to your app. It has several ways to receive background time, and each comes with its own limit. Apple's guide to choosing background strategies is the map; the table is the short version.

MechanismWhat Apple documentsWhat it is good for
App refresh task (BGAppRefreshTask)The system decides the best time to launch it and gives up to 30 seconds of background runtimeA small pull of fresh data
Processing task (BGProcessingTask)Can run for minutes, runs only when the device is idle, and is terminated when the user starts using the deviceHeavy, deferrable work such as maintenance or a large catch-up
Background URLSessionTransfers run in a separate process and continue when the app is suspended or terminated by the systemUploads and downloads that must finish
Background push (content-available)Low priority, delivery not guaranteed, throttled; the app has 30 seconds when wokenA hint that something changed
beginBackgroundTaskA limited amount of time after the app moves to the backgroundFinishing a save or a send that already started
Continued processing task (BGContinuedProcessingTask, iOS 26)Starts in the foreground from a person's action, shows progress in a Live Activity, the person can cancel itA long job the user started, such as an export

Four details change the design. First, the earliest begin date of a task request is a floor: the documentation says the system does not guarantee launching the task at that date, only that it will not begin sooner. Second, task identifiers must be listed in Info.plist and every task registered before the end of the app launch sequence. Third, a person can turn background refresh off, and scheduling then fails with an unavailable error; the same error comes back in the Simulator.

Fourth, force quit. If the user removes the app from the app switcher, the system cancels the background session's transfers and does not relaunch the app until the user opens it again. A held background notification is discarded if the app is force quit, and Apple asks you not to send more than two or three background pushes per hour. The background URLSession is the strongest tool iOS has, with limits worth knowing: only uploads from a file survive the app exiting, and a rate limiter delays new tasks started while the app is in the background, with the delay growing each time the system resumes the app.

What Android gives you: a durable queue that the system schedules

Android's answer is stronger and still not a schedule. WorkManager stores scheduled work in its own SQLite database and reschedules it across device reboots. Enqueue a request and it will run eventually. When it runs is the system's decision.

RuleWhat the Android documentation says
Periodic workMinimum repeat interval of 15 minutes, defined as the minimum time between repetitions; constraints can delay a run or skip it
ConstraintsNetwork type, battery not low, charging, device idle, storage not low; the work runs only when all are met
Expedited workFor short, user-important tasks; limited by a quota in the background, with an out-of-quota policy you choose
Worker timeA Worker gets a maximum of ten minutes to return a result, then it is signalled to stop
DozeSuspends network access and stops JobScheduler outside maintenance windows, which come less often the longer the device stays idle
App Standby bucketsActive, working set, frequent, rare, restricted; the rarer the use, the stricter the limits on jobs, and rare also limits network access
Restricted bucketJobs run once per day in a 10-minute batched session, even while charging

The sources: defining work requests, the Worker reference, Doze and App Standby and App Standby buckets. One detail makes the buckets less grim than they sound: for every bucket except restricted, the limits apply only while the device is on battery. Overnight on a charger, an app that waits for charging rides the easiest window of the day.

Three limits are user-shaped. A force stop keeps the app in the stopped state until the user launches or interacts with it again, and on Android 15 the system also cancels all of its pending intents when it enters that state. Google Play policy prohibits asking for an exemption from Doze and App Standby unless the app's core function is adversely affected. And high-priority FCM messages can wake a dozing device, but FCM may deprioritize them if they do not lead to notifications the user sees.

The table of what you can promise

Put the two lists side by side and the promises fall out. This is the table I would bring to the product conversation:

Promise to the productiOSAndroidVerdict
"Syncs every 15 minutes in the background"No: the begin date is a floorNo: 15 minutes is a minimum intervalNever promise it
"Syncs at an exact time, such as 6:00"NoNot for sync; the system batches work into windowsNever promise it
"Syncs eventually even if the app is never opened"Not promisable: refresh can be off, force quit stops relaunchUsually, with delay; once a day in the restricted bucket; not after a force stopPromise only as a best effort
"An upload the user started finishes while the app is in the background"Yes, through a background URLSession from a file, unless force quitYes, through WorkManager: a resumable worker, or long-running work with a notificationPromise, with a visible state
"Work the user did is never lost"Yes, if it is in the database before you ask for timeYes, same rulePromise it
"Data is current when the app opens"Yes, if a pass runs on every foregroundYes, same rulePromise it
"The screen says how old the data is"Yes, it is your UIYes, it is your UIPromise it, always

The three promises at the bottom are the ones engineering controls completely, and they are the ones users feel. The ones at the top depend on battery, usage, the user's settings and a scheduler you cannot inspect. A product built on the bottom three is reliable on both platforms. A product built on the top three is a support queue.

Design for a budget, not a schedule

Controlled Change gives this its own chapter, on process death, background work and time. The section heading carries the whole idea: background schedulers provide opportunity, not guarantees of timing. The design rule that follows is short: "A scheduler should wake a durable processor. It should not contain the only copy of work."

The processor is a pass over the operation ledger, the table where accepted user intent waits. The book lists its steps: query eligible operations, acquire a lease with an expiration, verify dependencies and account scope, make one bounded attempt, record the result (acknowledged, unknown outcome, retry time or terminal failure), release resources, and ask for future scheduling if work remains. Then the line that removes the platform difference from correctness: "Foreground resumption can run the same processor. Correctness is independent of which trigger supplied execution."

On iOS, that means the refresh task, the background push handler and the foreground launch all call one function. The scheduler only decides how early it runs:

Swift
import BackgroundTasks
import Foundation

// One sync pass: small, resumable, safe to cut off after any line.
protocol SyncEngine: Sendable {
    func runPass() async throws
}

final class BackgroundSync: Sendable {
    static let refreshID = "com.example.app.sync.refresh"
    let engine: any SyncEngine

    init(engine: any SyncEngine) { self.engine = engine }

    // Call before the app finishes launching.
    func register() {
        BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.refreshID, using: nil) { task in
            self.handle(task)
        }
    }

    // A floor, not a time: the system decides when, or whether, this runs.
    func scheduleRefresh() {
        let request = BGAppRefreshTaskRequest(identifier: Self.refreshID)
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
        try? BGTaskScheduler.shared.submit(request)
    }

    private func handle(_ task: BGTask) {
        scheduleRefresh()
        let pass = Task { try await engine.runPass() }
        task.expirationHandler = { pass.cancel() }
        Task {
            let finished = (try? await pass.value) != nil
            task.setTaskCompleted(success: finished)
        }
    }

    // The same pass runs on every foreground launch. This is the path you can promise.
    func appDidBecomeActive() async {
        try? await engine.runPass()
    }
}

The identifier also goes in Info.plist under the permitted task identifiers, and the app needs the Background fetch mode. On Android the shape is the same with WorkManager in place of the refresh task: unique work, so many triggers produce one drain, and a worker that calls the same pass. The book puts the division of labour in two sentences: "The scheduler asks, “May I run now?” The operation ledger answers, “What still must be resolved?”"

Persist before you ask, and assume you are cut off

Both platforms can end your background time in the middle of a line. iOS calls the expiration handler shortly before the time runs out, and a processing task ends when the user picks up the phone. Android signals a worker to stop at ten minutes, or earlier if the system preempts it. Controlled Change turns that into four rules for the platform layer: persist intent before requesting background execution, make workers restartable, assume a worker can stop after any line, and let foreground launches resume pending work.

In practice, each rule maps to one property of the pass:

  • Intent is committed first. The UI says "saved on this device" only after the local transaction succeeds. Nothing important lives only in a Task, a coroutine or a work request's input data.
  • Claims are leases. An operation marked "in progress" without an expiration is stranded forever if the process dies. A lease that expires lets the next pass reclaim it, on either platform, from either trigger.
  • Delivery is at least once. A pass killed after the server committed will send again. The book's line on leases applies here: they prevent concurrent processing, they do not make delivery exactly once. Idempotency keys created with the operation, and reused on every replay, make the second send harmless.
  • The cursor moves after the commit. Pull a page of changes, apply it in one transaction, then advance the cursor. A pass cut off between the two simply pulls the same page again.
  • Passes are small. Reclaim expired leases, claim a bounded batch, pull a bounded number of pages, record the next desired run, stop. A small pass fits inside the 30 seconds of an iOS refresh task; a large one is cut off and starts over.

The book's casebook, built around its fictional reference system Atlas, tests the design with a platform change: iOS background execution becomes more restrictive. The answer is three sentences: "The durable operation ledger remains valid. Scheduling adapters change, and operations resume on foreground. No UI task owns completion." A design that passes that test does not care which OS release tightens the budget next.

Make staleness visible

If the schedule cannot be promised, the age of the data must be shown. This is the half of background sync that most designs skip, and the half users actually see. Controlled Change treats freshness as part of the type: a value carries when it was fetched, how long it stays valid, its source and its sync status, so the UI can say what it knows.

The rule I would write into the design: the screen reads the time of the last successful sync, never a promise about the next one. Plain logic, the same on both platforms:

Kotlin
import java.time.Duration
import java.time.Instant

sealed interface Freshness {
    data object NeverSynced : Freshness
    data class Current(val syncedAt: Instant) : Freshness
    data class Stale(val syncedAt: Instant, val age: Duration) : Freshness
}

// The UI reads this, never a promise about when the next sync will run.
fun freshness(lastSuccessfulSync: Instant?, now: Instant, budget: Duration): Freshness {
    if (lastSuccessfulSync == null) return Freshness.NeverSynced
    val age = Duration.between(lastSuccessfulSync, now)
    return if (age <= budget) Freshness.Current(lastSuccessfulSync)
    else Freshness.Stale(lastSuccessfulSync, age)
}

The budget is a product decision, and it can differ per field. The book's example: a cached description can be shown for days, while price and availability must be revalidated before an action. Its list of offline budgets includes staleness (how old data may be for display and for action) and uncertainty (how long an outcome may stay unknown). Both belong in the spec next to the feature, not in a sync library's defaults.

The same honesty applies to pending work. The book separates saved on this device, waiting to send, sending, confirmation delayed, completed and needs attention, instead of one word, syncing, for everything. Background deferral is exactly the case where those states earn their keep: a user who wrote a note on the train should see "waiting to send", not a spinner that never ends.

Push is a hint, the foreground pull is the repair

Silent push and FCM look like a way around the scheduler. They are a faster path that still lands in the same pass. Apple's documentation is explicit that background notifications are low priority, not guaranteed and throttled; on iOS a newer background notification replaces an older held one. FCM delays normal-priority messages in Doze and can deprioritize high-priority messages that produce no visible notification.

Controlled Change has a line for the whole family of fast paths: "Push is a hint. Streams are accelerators. Durable state remains the recovery mechanism." So a push carries a reference, not the data. It starts a pass, and the pass pulls from the cursor. If the push never arrives, the next foreground launch pulls the same changes. If two pushes arrive, the pass runs twice and finds nothing new the second time.

For transfers, use each platform's real tool rather than stretching a refresh task. On iOS, a large upload goes from a file through a background URLSession, set to discretionary when it is not urgent so the system can wait for power and Wi-Fi. On Android, it goes through WorkManager with a network constraint and a worker that resumes from the last confirmed chunk. Either way, the ledger records which chunks the server acknowledged, so a cut-off pass resumes instead of restarting.

What to measure, so the budget is not a guess

A freshness budget is only a promise if you can see whether it holds. The first, second and fifth come from the book's chapter on building a sync engine; the third and fourth are mine. None of them needs user content:

  • Age of the oldest pending operation, by domain.
  • Cursor lag: how far the local cursor is behind the server.
  • Age of the data at the moment the app opens, which is the number the user experiences.
  • Share of passes that ran from background triggers versus foreground launches, by platform.
  • Unknown-outcome rate and retries before resolution.

Segment by platform and OS version, and on Android by charging state where you can. The third measure usually settles the debate about background sync. If data at open is already within budget for most users because of the foreground pass, background work is a refinement. If it is not, the fix is often a better foreground path or a push hint, not a more aggressive schedule.

The answer in a review or an interview

When the question comes, in a design review or a system design round, this is the answer I want to hear, in about a minute:

"Neither platform lets me promise a background schedule. iOS gives me opportunities: a refresh task with up to 30 seconds, an idle-time processing task, a background URLSession for transfers. Android gives me a durable queue in WorkManager, scheduled by the system under Doze and standby buckets, with a 15-minute floor for periodic work. So I persist intent first, run one small, idempotent, lease-based pass from every trigger, including every foreground launch, and show the user how old the data is. Background time only makes the data fresher. It never decides whether it is correct."

Then name the condition that changes it. The book's version is the cleanest exit I know: "Background execution is unnecessary when foreground recovery satisfies the product." A candidate who says that, and then asks what freshness the product actually needs, has turned a platform trivia question into an architecture decision, which is the signal the round is looking for.

Questions engineers ask about background sync on iOS and Android

Can I sync every 15 minutes in the background on iOS and Android?

No, on neither platform. On Android, 15 minutes is the minimum interval for periodic work, and the documentation calls it the minimum time between repetitions: constraints and system optimizations can delay a run or skip it. On iOS, the earliest begin date of a background task is a floor; the system decides when, or whether, it launches the task. Promise a freshness budget the user can see, not a schedule.

What is the difference between BGTaskScheduler and WorkManager?

WorkManager stores scheduled work in its own database and reschedules it across app restarts and device reboots, so deferred work on Android will run eventually unless the user force stops the app. BGTaskScheduler gives iOS apps opportunities to run: an app refresh task with up to 30 seconds, or a processing task that runs while the device is idle and can be interrupted. iOS has no persistent job queue of your own; your database has to be that queue.

Why does background sync stop working on some devices?

Because the user and the system are allowed to stop it. On iOS, a person can turn off background refresh, and an app the user force quit is not relaunched for background transfers. On Android, Doze defers jobs and suspends network access between maintenance windows, App Standby buckets ration rarely used apps, and a force stop keeps the app stopped until the user opens it again.

Should I use silent push or FCM to trigger sync?

Use them as accelerators, not as the delivery mechanism. Apple treats background notifications as low priority, does not guarantee delivery and throttles them; FCM may deprioritize high-priority messages that do not lead to a visible notification. A push should start the same sync pass a launch would, and the cursor pull on foreground repairs whatever the push missed.

Share this essay