There is no shared background work, because three platforms disagree about when
WorkManager schedules under constraints, iOS grants windows, the browser has none.
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,OpportunisticorForegroundOnly. - 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.

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.

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.

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.
Related
- A staged rollout you control on one store and not on the others — the same per-platform guarantee gap, measured on halt latency rather than delivery.
- One notification API, four delivery systems — the inbound direction, where the triggers differ as much as the schedules do here.
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".