Engineering

There is no shared background work, because three platforms disagree about when

WorkManager schedules under constraints, iOS grants windows, the browser has none.

MobileByteSensei 12 min read
kmpplatformarchitecture

TL;DR

  • A scheduleSync(every: 15.minutes) API in shared code. Measured over seven days: Android ran it 612 times, iOS 19, and the web target 0 — and all three had reported success at schedule time.
  • The work is portable. The trigger is a negotiation with an operating system that has its own opinion about when, and on one platform there is no scheduler to negotiate with.
  • The fix separates the job from its trigger, declares the trigger per platform, and makes the guarantee explicit in the type: Periodic, Opportunistic or ForegroundOnly.
  • What reading on buys: the seven-day delivery counts, why "every 15 minutes" is unachievable on two of three platforms, and the one design change that stopped the feature lying to its caller.

The case this post is about

An offline-first notes app, syncing pending edits:

// commonMain/kotlin/sync/SyncScheduler.kt
expect class SyncScheduler {
    /** Schedule a periodic sync. */
    fun schedule(interval: Duration)
    fun cancel()
}
// commonMain — called once at startup
class App(private val scheduler: SyncScheduler) {
    fun onStart() {
        scheduler.schedule(15.minutes)
    }
}

The actuals, each written against its platform's own documented API and each correct in isolation:

// androidMain
actual fun schedule(interval: Duration) {
    WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
        "sync", ExistingPeriodicWorkPolicy.UPDATE,
        PeriodicWorkRequestBuilder<SyncWorker>(interval.toJavaDuration())
            .setConstraints(Constraints.Builder().setRequiredNetworkType(CONNECTED).build())
            .build()
    )
}

// iosMain
actual fun schedule(interval: Duration) {
    val req = BGAppRefreshTaskRequest(identifier = "com.example.sync")
    req.earliestBeginDate = NSDate().dateByAddingTimeInterval(interval.inWholeSeconds.toDouble())
    BGTaskScheduler.sharedScheduler.submitTaskRequest(req, null)
}

// wasmJsMain
actual fun schedule(interval: Duration) {
    window.setInterval({ scope.launch { sync() } }, interval.inWholeMilliseconds.toInt())
}

Three schedulers, one shared call, and a product spec that said "edits sync within 15 minutes".

The symptom

A support pattern rather than a bug report: iOS users losing edits made offline, because the sync had not run before they reinstalled or the app was killed.

The seven-day measurement, from our own server receiving the sync requests:

# syncs received per platform, 7 days, 400 installs each
                    syncs    expected (672 = 7d / 15min)    median gap
  android             612                   672              16m
  ios                  19                   672             7h 41m
  web                   0                   672             —

Nineteen syncs where the spec said 672. And the web figure is the one that should have been obvious and was not: setInterval runs while the page is open, and a page that is closed has no timer — so a user who closed the tab had no background sync, which is the entire point of the feature.

Every schedule() call had returned normally. There was no error, no warning, and no way for the caller to learn that "every 15 minutes" had been accepted as a hint on one platform and as a no-op on another.

Seven days of sync deliveries per platform against an expected six hundred and seventy-two, with Android close to target, iOS at nineteen and the web target at zero — and every schedule call having returned normally on all three

Why the obvious fix failed

The first fix was to ask for a shorter interval on iOS, on the theory that the system would then grant more windows:

req.earliestBeginDate = NSDate().dateByAddingTimeInterval(60.0)   // "every minute"

Syncs over the next seven days: 19 → 21. earliestBeginDate is a floor, not a request — it means "not before this" — and the scheduler's decision about when to actually run is based on usage patterns, battery, charging state and how long previous runs took. Asking more often does not change the budget.

The second attempt was BGProcessingTaskRequest instead of app refresh, which permits longer runtimes:

val req = BGProcessingTaskRequest(identifier = "com.example.sync")
req.requiresNetworkConnectivity = true
req.requiresExternalPower = false

Syncs: 21 → 34. Better, and the runs clustered overnight while charging, which is exactly what the API is designed for and exactly wrong for "edits sync within 15 minutes".

The third attempt was a silent-push wake-up, which does work and changes the problem: it requires a server that knows an edit is pending, which it cannot know until the edit has synced. We do use it now for the inverse direction — remote changes waking the client — and it cannot solve the outbound case.

The fourth attempt, briefly, was a Service Worker for the web target. It is the right mechanism for offline-first web and its Background Sync API is not a timer either: it fires when connectivity returns, once, per registration. Which is a useful trigger and not a periodic one.

Four attempts, and the shape of each failure was the same: the platform had a scheduling model and I was trying to express a different one through it.

The actual mechanism

Three platforms, three scheduling models, and the differences are policy rather than capability.

WorkManager schedules under constraints and guarantees eventual execution. The minimum periodic interval is 15 minutes and the flex window means the actual gap varies. Doze defers work on an idle device. It is genuinely periodic over a long horizon — 612 of 672 is the system working as designed — and the individual gap is not controllable.

iOS grants discretionary windows. BGTaskScheduler is not a timer; it is a request to be considered. The system decides based on how often the user opens the app, battery level, charging state, network, and a per-app budget. An app the user opens twice a week gets very few windows, and that is correct behaviour protecting the device rather than a limitation to work around.

The browser has no background execution for a closed page. A setInterval lives in the page. A Service Worker can be woken by a push or by Background Sync on connectivity return, and neither is a schedule. There is no API that runs your code every fifteen minutes while the browser is closed, by design.

So the portable part is the job — read pending edits, push them, reconcile. The trigger is per-platform and the guarantee differs in kind, not degree:

  android   periodic, eventual, constraint-gated        → "it will run, within hours"
  ios       opportunistic, budgeted, unpredictable      → "it may run, sometimes"
  web       foreground only, or push/connectivity       → "it runs when the user is here"

A shared API returning Unit claims all three are the same. They are three different promises, and a feature spec that assumes the strongest one is a spec two platforms cannot meet.

Three scheduling models whose guarantees differ in kind rather than degree, with one promising eventual repeated execution, one promising only consideration against a budget, and one offering no background execution for a closed page

The fix

Separate the job from the trigger, and make the guarantee a value the caller receives — so the product decision about what to do on a weak-guarantee platform is made explicitly rather than by default.

// commonMain/kotlin/sync/Scheduling.kt
//
// The guarantee each platform can actually offer. Returned from schedule(), so a caller that
// needs a periodic guarantee can detect that it did not get one — which the Unit-returning
// version made impossible.
sealed interface Guarantee {
    /** Will run, eventually, repeatedly. The gap is not controllable. */
    data class Periodic(val minimumInterval: Duration, val typicalGap: Duration) : Guarantee

    /** MAY run, when the system grants a window. No lower bound on frequency. */
    data class Opportunistic(val observedMedianGap: Duration?) : Guarantee

    /** Runs only while the app is in the foreground, plus any declared event triggers. */
    data class ForegroundOnly(val eventTriggers: Set<EventTrigger>) : Guarantee
}

enum class EventTrigger { ConnectivityRestored, PushReceived, AppForegrounded }

/** The portable half: what to do, with no opinion about when. */
interface SyncJob { suspend fun run(): SyncResult }

expect class SyncScheduler {
    /** Registers [job] and returns the guarantee THIS platform can offer for it. */
    fun register(job: SyncJob, desired: Duration): Guarantee
    fun cancel()
}
// iosMain — the honest actual
actual fun register(job: SyncJob, desired: Duration): Guarantee {
    BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier(ID, null) { task ->
        // expirationHandler is not optional in practice: a task killed without cancelling
        // its work is reported as a failure by the system and REDUCES the app's future
        // budget, so ignoring it makes the next window less likely.
        task.expirationHandler = { jobScope.coroutineContext.cancelChildren() }
        jobScope.launch {
            val r = job.run()
            task.setTaskCompletedWithSuccess(r is SyncResult.Ok)
            submit(desired)                       // re-submit: one request grants ONE window
        }
    }
    submit(desired)

    // Deliberately reports the MEASURED median rather than the desired interval. A caller
    // asking for 15 minutes and receiving Opportunistic(7h 41m) can make a real decision;
    // one receiving Opportunistic(15.minutes) has been told the request, not the outcome.
    return Guarantee.Opportunistic(observedMedianGap = telemetry.medianSyncGap())
}
// wasmJsMain — the actual that stops pretending
actual fun register(job: SyncJob, desired: Duration): Guarantee {
    // A foreground timer, because that genuinely helps while the tab is open.
    window.setInterval({ jobScope.launch { job.run() } }, desired.inWholeMilliseconds.toInt())

    // Plus the two real event triggers the platform DOES offer.
    window.addEventListener("online", { jobScope.launch { job.run() } })
    document.addEventListener("visibilitychange", {
        if (!document.hidden) jobScope.launch { job.run() }
    })

    return Guarantee.ForegroundOnly(
        setOf(EventTrigger.ConnectivityRestored, EventTrigger.AppForegrounded)
    )
}

And the call site, where the guarantee drives a product decision:

// commonMain
class App(private val scheduler: SyncScheduler, private val ui: UiState) {

    fun onStart() {
        when (val g = scheduler.register(EditSyncJob(repo, api), desired = 15.minutes)) {

            is Guarantee.Periodic -> Unit        // the spec is met; nothing to tell the user

            // The spec is NOT met, and the user is the one holding unsynced edits. A banner
            // with a manual sync action is the compensating control — chosen once, here,
            // rather than being an absence the user discovers by losing work.
            is Guarantee.Opportunistic -> {
                ui.showPendingEditsHint(
                    "Edits sync in the background when your device allows it" +
                        (g.observedMedianGap?.let { ", usually within ${it.inWholeHours}h" } ?: ""),
                    action = ManualSync,
                )
            }

            // Strongest intervention, because the guarantee is weakest: sync before the page
            // can be closed, and say so.
            is Guarantee.ForegroundOnly -> {
                ui.showPendingEditsHint("Edits sync while this tab is open", action = ManualSync)
                // NOT jobScope.launch: the page is being torn down and the browser will not
                // wait for a coroutine. sendBeacon hands the payload to the browser, which
                // delivers it after the document is gone — fire-and-forget, and the only
                // thing here specified to survive unload.
                window.addEventListener("beforeunload", {
                    navigator.sendBeacon("/sync/flush", repo.pendingEditsAsJson())
                })
            }
        }
    }
}

And the output it produced, seven days after shipping, same 400 installs per platform:

$ ./tools/sync-delivery-report.sh --days 7

                    syncs   median gap   unsynced edits at 24h   guarantee reported
  android             618       16m               0.3%           Periodic(15m, ~16m)
  ios                  22     7h 22m               1.1%          Opportunistic(7h 22m)
  web                 891        n/a               0.4%          ForegroundOnly(2 triggers)

The iOS sync count barely moved — the system's budget is the system's. What changed is that 1.1% of iOS users with unsynced edits at 24 hours now see a banner and a manual sync button, and the web target went from 0 syncs to 891 because the foreground and connectivity triggers are real triggers that nobody had wired.

How we proved it

That all three had reported success while delivering three different things — the measurement that reframed the feature:

                schedule() returned   syncs in 7 days   spec met?
  android          normally                 612            yes
  ios              normally                  19            no
  web              normally                   0            no

A uniform return value across a 32× delivery difference.

That asking more often does not help on iOS. Four attempts, same seven-day window, same installs:

  BGAppRefresh, earliestBeginDate +15m        19 syncs   (no expirationHandler)
  BGAppRefresh, earliestBeginDate +60s        21 syncs   (no expirationHandler)
  BGAppRefresh + expirationHandler wired      22 syncs   ← compare against 21, same API
  BGProcessingTask                            34 syncs   (clustered 02:00–05:00, charging)

The third row is the one worth keeping, and it is worth keeping because it looks like nothing: 21 → 22 is noise. Wiring the expiration handler is not a way to get more windows. It is a way to stop losing them — a task the system kills while work is still running is recorded as a failure and shrinks the app's future budget, so the handler protects a count that would otherwise decay over a longer window than we measured. The 34-sync row is a different API, not a better result: BGProcessingTask buys overnight-while-charging runs, which is the wrong shape for this feature. The count is not the metric.

That the web target's 0 was a wiring gap and not a platform limit. Adding the two event triggers, nothing else:

  setInterval only                             0 syncs    (tab closed between sessions)
  + online event                             340 syncs
  + visibilitychange                          891 syncs
  + beforeunload sendBeacon flush             891 syncs   (0 new *scheduled* syncs;
                                                           61% fewer pending edits at 24h)

The beforeunload row changed no sync count and changed the outcome, which is the distinction the original metric could not see — the flush posts to a different endpoint and never appears in the scheduled-sync count at all. The first version used jobScope.launch and moved neither number, because the document was gone before the coroutine ran; that failure is what sent me to sendBeacon, which is the only browser API here specified to outlive its page.

That the guarantee is reported from measurement rather than from the request. The fixture asserts a fresh install reports Opportunistic(null) and that the value populates once telemetry exists:

$ ./gradlew :sync:iosSimulatorArm64Test --tests '*GuaranteeReportsObserved*'
PASSED   fresh install → Opportunistic(observedMedianGap = null)
PASSED   after 12 recorded syncs → Opportunistic(observedMedianGap = 7h 22m)

Reporting the desired interval here would have been the original bug wearing a sealed class.

What I cannot tell you is whether the iOS numbers would improve for a more frequently-opened app. Our users open this app a few times a week, and BGTaskScheduler's budget is driven by usage — so an app people open hourly would likely see far more windows. The 19-to-22 figure is this app's usage pattern, not iOS's ceiling, and I have no measurement from a high-engagement app to compare against.

And the limit of the whole design. None of this makes iOS periodic. The banner is a compensating control that moves the failure from silent data loss to a visible prompt, and a user who ignores the prompt still loses edits on reinstall. Genuinely guaranteeing outbound sync on iOS needs the user to open the app, and no API changes that.

The four iOS attempts and the three web triggers measured over the same window, showing that wiring the expiration handler lowered the raw count while raising completions, and that a page-unload flush changed no sync count and cut pending edits by sixty-one percent

What this generalizes to

When a capability's availability differs across platforms you write an expect; when its guarantee differs you have to return the guarantee, because a caller cannot compensate for a promise it was not told about. The useful test for any scheduling, caching or delivery abstraction is to ask what the weakest platform actually commits to, and then check whether the shared signature can express that commitment — a Unit return cannot, so it silently publishes the strongest platform's behaviour as the contract and leaves every other platform's users to discover the difference.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS