Skip to content
AFAIFitnessAPI
Architecture

Background Sync That Does Not Depend on the Phone Waking Up

Updated July 27, 2026

Treat every background wake as an opportunistic hint, never as a delivery guarantee. Apple documents only an upper bound on HealthKit background delivery (at most once per period, hourly-capped for step count on iOS) and a shutdown after three unacknowledged deliveries, while Health Connect has no new-data callback at all. That single constraint drives the design: pair each wake with a foreground reconciliation on the next app open and a server-side staleness check that marks a user's data unknown rather than zero. Do that, rather than running a nightly job that assumes last night's wake fired.

The failure this prevents is specific and embarrassing. A coaching backend computes each user's weekly summary at 03:00. For a user whose phone did not deliver a background wake since Friday, the job reads its own tables, finds no samples for Saturday and Sunday, and emails them a summary saying they took zero steps over the weekend. The user walked 14,000 steps on Saturday. Nothing was lost — the samples are sitting in HealthKit on their phone, and they will arrive the next time the app opens. The bug is that the server treated absence of a delivery as evidence of a value. Every design decision below exists to keep those two apart.

What the platforms actually document

This topic attracts more confident, unsourced claims than any other part of health-data engineering. It is worth separating what Apple and Google write down from what developers repeat.

Apple: the mechanism is documented, the reliability is not

The mechanism is precise. An HKObserverQuery sets up "a long-running task on a background queue" that watches the store and "alerts you when the store saves or removes matching data." Registering the type with enableBackgroundDelivery(for:frequency:withCompletion:) means, in Apple's words, "HealthKit wakes your app whenever a process saves or deletes samples of the specified type."

The observer tells you that something changed, not what. Apple's own instruction is to launch a second query from inside the update handler: "You often need to launch other queries from inside this block to get the updated data. In particular, you can use Anchored Object Queries to retrieve the list of new samples." That indirection is not optional — Apple documents that anchored object queries, statistics collection queries and activity summary queries cannot themselves register for background delivery.

Three documented properties then decide your architecture.

The frequency parameter is a ceiling, not a floor. frequency is "The maximum frequency of the updates. The system wakes your app from the background at most once per time period specified." HKUpdateFrequency offers immediate, hourly, daily and weekly. Apple documents no minimum delivery rate, no latency figure and no delivery guarantee for observer-query wakes anywhere in that documentation. Anything you build on top of a wake is built on an upper bound.

Step count is hourly-capped on iOS no matter what you request. Apple: "Some sample types have a maximum frequency of hourly. The system enforces this frequency transparently. For example, on iOS, stepCount samples have an hourly maximum frequency." Requesting .immediate for steps on iOS is not an error; it is inert. On watchOS most types are hourly-max too, with a documented exception list that can arrive at immediate — including highHeartRateEvent, lowHeartRateEvent, irregularHeartRhythmEvent, vo2Max and numberOfTimesFallen. Apple also documents a watchOS budget of four updates (or background app refresh tasks) an hour, but read the whole sentence: it is conditioned on the app having a complication on the active watch face. No complication, no documented budget.

Three unacknowledged deliveries and Apple switches you off. This is the single most consequential documented fact on the page: "If you don't call the update's completion handler, HealthKit continues to attempt to launch your app using a backoff algorithm to increase the delay between attempts. If your app fails to respond three times, HealthKit assumes your app can't receive data and stops sending background updates." An Apple DTS engineer confirmed on the developer forums that this shutdown "is an as-designed behavior."

So the completion handler is not bookkeeping you can skip on the error path. It is the heartbeat that keeps the channel alive, and the error path is exactly where people forget it — a thrown error, an early return on a nil unwrap, a network call that times out before the handler is reached. Call it on every path:

let observer = HKObserverQuery(sampleType: stepType, predicate: nil) { _, completionHandler, error in
    // Acknowledge on EVERY path, including the failure branch.
    defer { completionHandler() }

    if let error {
        // errorDatabaseInaccessible is retryable, not "no data" — the device is locked.
        log.error("observer wake failed: \(error)")
        markRetryOnUnlock()
        return
    }
    // Persist locally first; the anchor is the durability boundary, not the handler.
    ingestFromPersistedAnchor()
}

That ordering matters and is our judgement, not Apple's wording: persist the samples and the new anchor to local durable storage before acknowledging, then upload asynchronously. A failed upload costs you latency because the anchor has not advanced past unsent data. A missed completion handler costs you the entire delivery channel, and you get three of those.

Apple documents several other things that stop delivery dead, and each is worth a line in your setup checklist. Since iOS 15 and watchOS 8 you need the com.apple.developer.healthkit.background-delivery entitlement, without which enableBackgroundDelivery fails with errorAuthorizationDenied. Observer queries must be registered in application(_:didFinishLaunchingWithOptions:) so they exist before HealthKit delivers to a freshly launched process; registering them lazily in a view controller means a background wake finds no matching query. Background delivery does not work in the Simulator at all — Apple says so on two separate pages. And when the device is locked, the HealthKit store is encrypted: "your app may not be able to read data from the store when it runs in the background," surfacing as errorDatabaseInaccessible. A wake on a locked phone can therefore deliver an error instead of data, which your pipeline must treat as retryable rather than as a zero.

The folklore, named as folklore

"Force-quitting the app kills HealthKit background delivery." This is repeated everywhere, and we could not find it stated anywhere on developer.apple.com — not in documentation, not in an Apple-staff forum reply. Developers raise it in the forums as an open question and get no Apple answer. It is plausible and probably true, because it matches general iOS background-execution behaviour, but it is not Apple's documented position and you should not write it into a support script, a help-centre article or a user-facing instruction as though it were. The design response is better than the debate anyway: build so that if a force-quit does stop delivery, the next app open reconciles and nothing is lost. That is the same mechanism you already need for a phone that was simply off.

"Hourly means you get an update every hour." The documentation says at most once per period. Developers report roughly two updates over five hours with .hourly configured — a peer observation with no Apple reply, but consistent with reading the parameter as a ceiling.

"Once configured correctly, delivery is stable." A 2025 forum thread reports watchOS background delivery stopping after two to three days with the completion handler correctly called on all paths; Apple's DTS response was to file a feedback report and track it as a reported issue. That is an acknowledged bug, not documented behaviour — but it tells you that a correctly implemented channel can still go quiet for reasons entirely outside your code.

Android: the permission grants access, not execution

Health Connect has no observer query and no push. Google states it flatly: "As your app can't get notified of new data, check for new data at two points: Each time your app becomes active in the foreground. In this case, use lifecycle events. Periodically, while your app remains in the foreground."

The default is foreground-only — "Apps can only read data from Health Connect while they are in the foreground." The extra permission that changes this is, verbatim in the manifest:

<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND" />

Google documents that you must also check availability at runtime before relying on it, comparing HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND against HealthConnectFeatures.FEATURE_STATUS_AVAILABLE via healthConnectClient.features.getFeatureStatus(...). (Verify the Jetpack permission constant against the SDK before you print it in code — the guide shows the manifest string and the feature constant, and those are the two we can vouch for.) Google also asks you to degrade gracefully on a partial grant: if the user does not grant everything background reads need, the app "should still run, and it should perform as many tasks as it can with the permissions that the user granted."

What that permission does not buy you is execution time. Google documents that "access to Health Connect may be interrupted at any point," that your app "must handle interruptions midway through a sync when reading a large amount of data," and that it should "continue the next time the app is opened." Background rate limiting is documented as stricter than foreground, and Google publishes no numbers for either — so an adaptive loop that pages, catches the documented quota error and backs off is the only defensible shape. Do not tune to a figure you found in a blog post.

WorkManager is our recommendation, not Google's. It is not mentioned on any of the Health Connect sync, read, background or rate-limiting pages. We use it because it survives process death and reboot, but every run is best-effort: the OS decides when, Doze can defer it indefinitely, background quota is tighter, and the read can be cut off mid-page. It is a scheduler, not a promise.

There is a deadline hiding in this on Android that has no iOS equivalent. Changes tokens expire within 30 days, documented. If your only reader is a background worker the system keeps deferring and the user never opens the app, the token dies, and you fall back to a re-read window — and if the user never granted PERMISSION_READ_HEALTH_DATA_HISTORY, everything older than the default 30-day window is not recoverable at all. Background sync failure on Android is therefore not just latency; past a month it is permanent data loss. The mirror-image risk on iOS is deletions: Apple documents deleted objects as temporary and purgeable, and names observer-plus-background-delivery as the only path that guarantees you see them. A phone that never wakes can lose a deletion permanently, and no server-side diff recovers it, because the sample is simply not in HealthKit any more to diff against.

What each platform will and will not promise you

PlatformWhat is documentedWhat you must not assume
iOS — delivery"HealthKit wakes your app whenever a process saves or deletes samples of the specified type"; frequency is "the maximum frequency … at most once per time period"That any wake happens, or happens within a bounded time. Apple documents no minimum rate and no SLA.
iOS — step countstepCount has an hourly maximum frequency on iOS, "enforce[d] transparently"That .immediate produces sub-hourly step wakes. It is silently downgraded.
iOS — acknowledgementThree unacknowledged deliveries and HealthKit "stops sending background updates"; DTS calls the shutdown as-designedThat a missed completion handler is harmless, or that the channel repairs itself.
iOS — force quitNothing. No primary source found on developer.apple.comThat it is documented Apple behaviour — and equally, that it never happens. Design around both.
iOS — locked deviceStore is encrypted on lock; background reads may fail with errorDatabaseInaccessible; writes are bufferedThat an empty background read means the user did nothing.
iOS — registrationRegister observer queries in application(_:didFinishLaunchingWithOptions:); background delivery is unsupported on SimulatorThat a lazily registered observer catches a wake, or that Simulator testing proves the channel works.
Android — notificationNo new-data callback exists; check on foreground entry and periodically while foregroundedThat Health Connect will ever push, subscribe or call back.
Android — background readsManifest permission plus a runtime FEATURE_READ_HEALTH_DATA_IN_BACKGROUND availability checkThat permission schedules you, or that a started read completes. Interruption "at any point" is documented.
Android — quotaBackground rate limiting is stricter than foreground; categories documented, values notAny specific requests-per-hour or per-day number. Google publishes none.
Android — change tokensAn unused changes token expires within 30 daysThat a background worker alone keeps it alive. Past expiry, ungranted history is unrecoverable.

What we would actually build

Three legs, with the correctness argument resting entirely on the second and third.

Leg 1 — the wake does the cheapest correct thing and gets out. On an iOS wake: run the anchored object query from the persisted anchor, write raw samples to local durable storage idempotently keyed on uuid, push the affected civil dates onto a local dirty_days set, persist the new anchor, call the completion handler, and upload asynchronously. Do not compute a daily total inside the wake, and above all do not make the wake the only place a total is ever computed. On Android the equivalent is a WorkManager job doing a getChanges loop while hasMore is true, checking changesTokenExpired on every response, and checkpointing the nextChangesToken after each page so an interrupted run resumes rather than restarts.

Leg 2 — foreground reconciliation is the leg you can actually reason about. On every app open, unconditionally: run the anchored query up to the current anchor, then use the returned samples' dates only to decide which days are dirty, and run a statistics collection query to recompute what each of those days now totals. That is Apple's own prescribed shape from WWDC20, and the reason it matters here is that it makes a missed wake cost nothing but latency: whether you were woken zero times or forty, the recomputation for a dirty day produces the same number. Never sum the anchored query's samples into a running counter — that is the design that turns a missed wake into a wrong number, and it is covered in depth in incremental sync. Google's own model for Android is literally "continue the next time the app is opened," so the platforms agree on this even though only one of them can wake you.

Leg 3 — the server decides what it does not know. This is the part most teams skip, and it is what stops the 03:00 job from emailing a zero. Keep sync state per user, per provider, per metric, and keep two timestamps that are almost always conflated:

create table sync_state (
  user_id                   uuid        not null,
  provider                  text        not null,  -- 'healthkit' | 'health_connect' | ...
  metric                    text        not null,
  cursor                    bytea,                 -- opaque: archived HKQueryAnchor / changes token
  cursor_kind               text        not null,
  last_client_checkin_at    timestamptz,           -- a device actually ran a sync and reported in
  last_sample_at            timestamptz,           -- newest sample we hold
  expected_checkin_interval interval    not null,  -- per provider, per cohort
  primary key (user_id, provider, metric)
);

last_sample_at and last_client_checkin_at answer different questions, and only the second one is about your pipeline. A user who genuinely rested yesterday and a user whose phone never woke both produce "no new samples." Only the check-in timestamp separates them. Apple makes this ambiguity worse by design on the read side — a denied read permission is documented as indistinguishable from an empty store — so on iOS an empty result is at minimum four-way ambiguous between "no activity", "permission denied", "limited authorization window" and "device was locked."

The staleness check is then trivial to express and hard to argue with:

select user_id, provider, metric,
       now() - last_client_checkin_at as silence
from sync_state
where last_client_checkin_at is null
   or last_client_checkin_at < now() - expected_checkin_interval;

Two health-specific rules make it useful rather than noisy. First, expected_checkin_interval has to be per provider and per cohort, never global: a HealthKit user checks in when they open your app, a watch user when the watch syncs, a ring user when the ring is charged. One global threshold either screams constantly or never fires. Second, page on the fraction of a provider's active users past their threshold, not on individual users — a provider-side outage shows up as a drift in that fraction long before it shows up as zero rows, because a few users always sync. That monitoring shape is developed further in data quality monitoring.

Then the rule that makes the whole design pay off: model every (user, metric, day) cell as known(value) or unknown, never as a bare number. A stale user's yesterday is unknown. Serve unknown to the UI as "waiting for your phone", suppress it from streaks, goals and weekly emails, and never fold it to 0. Writing a zero is a data-corruption bug that outlives the sync problem that caused it, and it is the subject of missing data and gaps.

The honest limits

Nothing in this design makes a background wake happen. If a user's phone is off for a week, your server is uncertain for a week; the design's only claim is that being uncertain is better than being confidently wrong, and that recovery on next open is complete rather than partial.

Deletions remain genuinely lossy on iOS. Apple documents deleted objects as temporary and purgeable, so a device offline long enough loses the record permanently and there is no reconciliation that recovers it. The only mitigation we know is periodic full-window re-reads with set-difference reconciliation against what you hold, which costs time and quota and which we would run monthly rather than daily.

Anchor durability across app reinstall or device restore is not documented. Apple documents only that HKQueryAnchor adopts NSSecureCoding and can be persisted; everything past that is unverified, and developers report cases where a persisted anchor caused a full replay of the store. Assume a full replay is possible at any time and make ingestion idempotent on sample uuid so that a replay is a no-op rather than a doubling. The same discipline applies to a long historical backfill running alongside the live stream.

Health Connect quota numbers are not published by Google — only the categories. Design to degrade (page, catch the quota error, back off, resume from the stored page token) rather than tuning a loop to a number someone posted in a forum.

On buying instead of building, split the decision by where the data lives. For cloud-backed providers — Fitbit, Garmin, Oura, WHOOP, Strava — an aggregator such as Terra, Rook, Sahha or Vital removes this entire problem class, because the data reaches your server without a phone in the path at all. There is no wake to miss. That is frequently the right purchase and it straightforwardly beats building, and we would not talk a team out of it. What an aggregator does not remove is the on-device case: HealthKit and Health Connect are local stores, so an aggregator's mobile SDK runs inside your app under the same wake ceiling, the same three-strikes rule, the same foreground-only Android default and the same quota. Buying changes who writes the reconciliation code; it does not change whether the phone has to wake up. Our position: buy everything cloud-to-cloud, and own the client leg yourself when on-device data is core to the product, because that is the leg where your product's correctness lives. The trade-offs are laid out in health data aggregator APIs and in on-device vs cloud health data.

Where to go next

If you have a live symptom rather than a design question, start with the symptom pages: HealthKit returning no data, Health Connect returning no data, and wearable data arriving late. Then come back and make the symptom impossible to matter: a wake you never got should cost you minutes of freshness, not a wrong number in a user's weekly summary.

Frequently asked questions

Does force-quitting an iOS app stop HealthKit background delivery?
It is very widely repeated and probably true, but it is not documented. We found no statement on developer.apple.com, in documentation or in an Apple-staff forum reply, confirming it for HealthKit specifically. Treat it as expected but unconfirmed rather than as Apple's position, and design so it does not matter: if a force quit does stop delivery, a foreground reconciliation on the next app open recovers everything, which is the same mechanism you need for a phone that was simply switched off.
What happens if my observer query does not call its completion handler?
Apple documents that HealthKit retries using a backoff algorithm, and that if your app fails to respond three times HealthKit assumes it cannot receive data and stops sending background updates. An Apple DTS engineer has confirmed on the developer forums that this shutdown is as-designed behaviour. That makes the completion handler the heartbeat that keeps the channel alive rather than optional bookkeeping, so call it on every code path, including the error branch and every early return.
Why does requesting immediate background delivery for step count change nothing on iOS?
Apple documents that some sample types have a maximum frequency of hourly and that the system enforces this frequency transparently, naming step count on iOS as the example. Requesting immediate is not an error, it is inert. Plan step pipelines around hourly at best, and remember that the frequency parameter is documented as a maximum, so hourly does not promise one wake every hour either.
Can Health Connect notify my app when new health data arrives?
No. Google documents that your app cannot get notified of new data, and instead tells you to check at two points: each time your app becomes active in the foreground, using lifecycle events, and periodically while it remains in the foreground. The background read permission lets you read while backgrounded, but it does not schedule your process and does not guarantee a read completes; Google documents that access may be interrupted at any point and that you should continue the next time the app is opened.
Why do my background HealthKit reads fail while the phone is locked?
Apple documents that the device encrypts the HealthKit store when the user locks it, so your app may not be able to read from the store while running in the background. The concrete error is errorDatabaseInaccessible. Writes still succeed and are cached until unlock. Treat that error as retryable rather than as an absence of data, still call the completion handler, and re-run the anchored query once the device is unlocked.

Keep reading

Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All architecture · by AIFitnessAPI