Wearable data is late because it crosses several clocks before your server sees it, and which clocks depends on the route it takes. On the on-device route, the vendor app has to write it into Apple Health or Health Connect, and the phone has to let your app read it. On the cloud route, the vendor app has to sync it to the vendor’s servers, and the vendor has to tell you. On both, the watch has to reach the phone first, and a permission can make “not here yet” look identical to “never coming.” Your API is the last step, and usually the shortest.
A workout can be on the wrist at 7:40 and still missing from your database at 10:00. That is not one bug. It is whichever step on that user’s route has not moved. This is how each route works, and how to tell which step you are staring at.
The consent map underneath this is four authorization models. The pipeline that has to revise late and out-of-order samples is building a health data pipeline.
Two routes, and where time goes
A wearable sample reaches your server in one of two ways. On the on-device route, the vendor’s phone app writes it into Apple Health or Health Connect, and your own app reads it there and uploads it. On the cloud route, the vendor’s phone app uploads it to the vendor’s servers, and your backend receives a webhook or polls the vendor’s API. A given sample takes one route or the other. The only shared steps are the first, where the device syncs to its phone app, and the last, where your backend stores it.
| Step | Route | What has to happen | Typical lag | What the ticket looks like |
|---|---|---|---|---|
| Device to phone app | Both | Bluetooth while the phone is near; Wi-Fi if it is not. No connection, no sync [1] [2] | Not published. Nothing moves while the link is down | Sleep still “in progress” after the user is at their desk |
| Vendor app writes Apple Health or Health Connect | On-device | Garmin, Oura, Samsung Health, or another app must write the store. Often a toggle the user has never seen. Apple Watch writes HealthKit itself | Until that app writes. It can be never | Workout is in Connect. It is not in the Health app |
| OS lets your app read | On-device | HealthKit may wake you, at most on a cap, and not reliably. Health Connect will not wake you at all [3] [6] | Up to an hour, or only when the app opens | Data appears the moment someone launches the app |
| Vendor app syncs to its cloud | Cloud | The vendor’s app uploads on its own schedule, then the vendor processes the data [10] | The vendor’s schedule | The vendor’s app shows it. Your API has nothing |
| Webhook or poll | Cloud | The vendor notifies you, or your poll finds it, then you fetch the record [16] [17] [18] | Seconds after processing, plus retries | A dropped webhook, and yesterday until the next poll |
| Permission | Both, as a check | On-device: read grant, and on Android separate background and history grants. Denial is silent on iOS [9] [7]. Cloud: OAuth consent | Indefinite | ”Sync is slow” for one user and fine for everyone else |
| Your backend | Both | Upload or fetch, score, webhook | Seconds to a few minutes after the sample arrives | The only step you can tighten |
Both routes: the wearable has not sent it
This step is shared, so it delays both routes. Apple Watch does not hand your backend a sample. It talks to the paired iPhone. Apple’s support note on radios is the power constraint in one sentence: the Watch uses Bluetooth when the iPhone is near, because that conserves power, and tries Wi-Fi when Bluetooth is not available [1]. A red disconnected icon on the watch means that link is down [2]. Health samples do not cross a link that is down.
Apple does not publish how often those samples land in the Health store. There is no interval to put in a spec. What you can verify is whether the sample exists on the phone at all: Health > a metric > Data Sources, or the same type in Health Connect. If it is not there, your observer query and your scoring job are waiting on a write that has not happened.
Third-party watches sync to their own app first, and that app decides which route the data can take next. Garmin documents a one-way write from Connect into Apple Health, started by the user [11]. Connect can show the workout while Health does not, and Health can show steps while Body Battery stays in Connect. The Garmin pause post is the payload gap. The latency point is simpler: until Connect writes to the phone’s store (the on-device route), or a partner Health API push fires after a Connect sync (the cloud route) [10], that workout is not on a clock you control.
The same shape holds for any wearable that syncs to its own phone app first. Samsung Health, Oura, WHOOP. The wrist is not the source your server reads. The phone app’s last successful sync is.
Samsung shows how many clocks the on-device route can stack. Samsung’s documentation says that once Samsung Health and Health Connect are linked, Samsung Health writes to Health Connect as soon as data is created or changed. The link is a setting the user has to turn on, and the Galaxy Watch to phone sync before it “follows its own policy” for battery [19]. A sample can be on the watch, then in Samsung Health, and only then in Health Connect for your app to read. Samsung also offers its own on-device Samsung Health Data SDK, which reads from Samsung Health directly and skips the Health Connect write. It is still the on-device route, so the phone’s background rules still apply.
On-device route: the phone is saving power
This step only exists on the on-device route, because only there does your own app have to run on the phone. It is the step people file as “our sync is broken” after it worked in the foreground, on a debugger, with a charging phone.
HealthKit will wake you, up to a cap, and then it may not
enableBackgroundDelivery(for:frequency:withCompletion:) asks iOS to launch your app when samples of a type are saved. Two limits are in the same paragraph of Apple’s docs [3].
The frequency you pass is a maximum. The system wakes you at most once per period. Some types are capped at HKUpdateFrequency.hourly, and iOS enforces that cap even if you ask for .immediate. Step count is the documented example [3]. An Apple engineer on the developer forums has said the window is not a guarantee either: background-execution budget, battery level, and other apps competing for the same budget can all delay or skip the wake, and there is no API to change that [5].
Three further ways this step goes quiet:
- No entitlement. On iOS 15 and later,
com.apple.developer.healthkit.background-deliveryis required. Without it,enableBackgroundDeliveryfails with authorization denied [3]. The app works whenever someone opens it. That is the entire bug. - Observer registered too late. HealthKit delivers to observer queries that already exist. Apple tells you to set them up in
application(_:didFinishLaunchingWithOptions:)so they exist before a background launch finishes [3]. - Completion handler skipped. You must call it when you are done processing. If you do not, HealthKit retries with backoff. After three failures it assumes you cannot receive data and stops background updates [4]. A handler that waits on your HTTP POST is how a slow server becomes a dead observer. Persist, call the handler, upload after. The HealthKit rejection post has the same failure from the review side.
Background delivery also does not run for server queries in the Simulator [3]. A delay you can only see on a plugged-in debug session is not the delay your users have.
Health Connect will not wake you
Android’s sync guide is blunt: “As your app can’t get notified of new data,” you check when the app comes to the foreground, and periodically while it is in the foreground [6]. There is no HealthKit-style observer for another app’s steps.
Background read is an extra permission, READ_HEALTH_DATA_IN_BACKGROUND [7]. If the user grants it, you may read while backgrounded. You still have to schedule the read yourself. Google’s own sample uses a periodic worker on a one-hour interval [12]. Doze defers background work when the phone is stationary, screen off, on battery [13]. Manufacturer battery managers sit on top of that and are outside the Health Connect API. Google’s own troubleshooting list for “data hasn’t synced” starts with permissions and a sync the user never started [8].
So “Android is slower than iOS” is often this: iOS may push within a capped window; Android polls, and the poll is allowed to wait. HealthKit versus Health Connect is the platform comparison. The operational consequence is that an Android freshness SLO copied from an iPhone test is fiction.
On-device route: a permission looks like a delay
Late and absent are different bugs. The on-device stores are built so your code often cannot tell them apart. Cloud permissions fail more loudly, as covered in the next section.
HealthKit hides read denial. authorizationStatus(for:) reports whether your app may save a type. It does not report whether the user allowed a read. A query returns samples or an empty array. Empty is also a missing watch, a denied toggle, or a day with no steps [9]. Designing a spinner for “syncing…” on that empty array trains the user to wait for data the OS will never deliver.
Health Connect splits the grant. The type, READ_HEALTH_DATA_HISTORY, and READ_HEALTH_DATA_IN_BACKGROUND are separate [7]. History is not latency: without it you cannot read older than about 30 days before the first grant, and that request errors rather than returning slowly [14]. Background is latency: decline it and you are foreground-only, which looks exactly like the symptom in the previous section. getGrantedPermissions will tell you which of the three is missing. Use it before you add retry.
Revocation is not a retry case. The user can turn a type off in the Health app or in Health Connect settings after a week of perfect sync. The next empty window is a permission change. An unused Health Connect changes token expires within 30 days, and the recovery is a resync from the last timestamp you stored, not another poll with the dead token [6]. Treating expiry as “the API is slow” replays the wrong cursor and stays empty.
Force-quit is a permission the user thinks is a gesture. Apple’s background-execution guide says that, in most cases, the system does not relaunch an app after the user force-quits it. The user has to launch it, or reboot the device, before automatic background launches resume [15]. Support will hear “it only updates when I open it.” That sentence is accurate. It is not a server incident.
Cloud route: the vendor’s clock
The cloud route skips the phone’s health store entirely. Your app does not need to be woken or opened on the phone, so none of the HealthKit and Health Connect limits above apply. The vendor’s own app still runs on the same phone, under the same power rules, and it is the one that has to sync. OAuth wearables feel like the exception because a server can pull. They are still a clock you do not set.
The device syncs to the vendor’s cloud when the vendor’s app decides to, with the same Bluetooth and battery constraints as the first step. Your backend then finds out through a webhook or a poll. Webhooks get dropped. A pipeline that treats a webhook as the only ingest will show yesterday until something else retries. Polling has rate limits, so the retry interval is another deliberate lag. Building a health data pipeline is the idempotent write you need once those retries duplicate rows.
A revoked grant usually shows up as a 401, not a long request. Token refresh failures should page a different alarm from “no samples in this window.”
Garmin’s Health API, for apps that already have credentials, delivers JSON summaries after the user syncs to Garmin Connect [10]. The workout ending on the watch is not the event. Connect’s sync is. Training API pushes, the ones that send a workout to the watch, are a different direction and do not make inbound summaries faster.
If you are not a Garmin partner, you do not get the cloud route at all. You get whatever Connect wrote into HealthKit or Health Connect, on the user’s toggle, on the phone’s background policy. Two users with the same watch and different backends are not a scoring discrepancy. They are on different routes.
The other clouds follow the same rule: the webhook is quick, but it cannot fire before the device has synced to its own app.
- Oura. Data reaches Oura’s cloud when the ring syncs through the Oura app. API v2 webhooks then announce create, update and delete events for types such as sleep, readiness, activity and workouts, and carry an object id rather than the record, so you make a second request to fetch it [18].
- WHOOP. Webhooks such as
sleep.updated,recovery.updatedandworkout.updatedarrive once WHOOP has processed a change. Editing a past sleep produces both a sleep and a recovery event. Failed deliveries are retried five times over about an hour, and WHOOP asks endpoints to respond within a second, which means acknowledge first and fetch later [17]. - Google Health API. The replacement for the Fitbit Web API pushes notifications for more than 20 data types, including steps, sleep, heart rate and weight. Notifications carry the user, data type and time interval to query, arrive in batches of up to 99, and failed deliveries are retried with exponential backoff for up to seven days [16]. A long retry window is generous, and it also means an outage on your side can surface as a burst of old data days later.
Which route to use
Most teams do not choose one route for everything. They choose per device and per data type, because the routes carry different data as well as running on different clocks.
| On-device: HealthKit or Health Connect | Cloud: vendor API | |
|---|---|---|
| Devices | Any device whose app writes the store. The only route for Apple Watch and Samsung Galaxy Watch (Samsung also has an on-device SDK) | Oura, WHOOP, Garmin for existing partners, and Fitbit through the Google Health API |
| What you get | What the vendor app chooses to write. Composites such as Garmin’s Body Battery stay behind [11] | The vendor’s own records and scores |
| Timing depends on | The vendor app’s write, then your app being woken or opened | The vendor app’s cloud sync, then the webhook |
| Needs your app on the phone | Yes | No |
| Access | Store permissions granted in your app | OAuth, plus partner approval where the vendor requires it |
Neither route is reliably faster. Both wait for the device to sync to its phone app, and after that one waits on the phone’s background rules while the other waits on the vendor’s processing.
Some devices offer both routes. Oura, for example, writes to Apple Health and also runs its own API. If you ingest both for the same user, the same night arrives twice, at different times, and sometimes with different values. That looks like a late revision or a duplicate. Decide which route is the source of truth for each data type before you connect the second one.
Your backend is the only clock you own
Once a sample reaches you, uploaded by your app or fetched after a vendor webhook, the remaining work is deduplicate, score, and webhook. That is seconds to a few minutes in a sane pipeline. It cannot start until one of the routes above has delivered a sample.
Four backend mistakes that get blamed on the wearable:
- Waiting to ack. Holding a HealthKit completion handler open across the upload, as above, trains iOS to stop waking you [4].
- Bucketing on server time. A sleep session that ends at 7:40 in the user’s zone, stored as a UTC date, looks like an eight-hour hole or a duplicate night. That is a timestamp bug with the symptoms of lag. Which day a sleep session belongs to is the rule. Carry the zone with the sample.
- Ingesting both routes without a rule. As above, the same night lands twice, hours apart, and the second copy looks like a correction. Pick one source per data type.
- Alerting on emptiness. No new rows is what a resting user, a denied read, a deferred background wake, and a down worker all produce. Alert on “granted permission, device seen recently, no samples past the expected delay for that route,” not on “zero.”
What changed in September 2026
Three changes this month move the steps above.
The Fitbit Web API shuts down on 30 September. Cloud access to Fitbit data moves to the Google Health API, which has the stronger push model described above [16]. OAuth tokens do not transfer, so every connected user has to consent again [20]. A user who never re-consents will look like permanently late data. That is a permission failure, not a slow cloud. The move is covered in the Fitbit Web API migration guide.
Garmin paused its developer program. Apps without existing Health API access can no longer take the cloud route for Garmin. They depend on Connect writing into Apple Health or Health Connect, which puts the vendor-app write and the operating system’s background rules in front of every Garmin sample. The details are in Garmin paused its developer program.
Apple Watch Series 12 measures heart rate every five seconds. Apple describes how often the watch measures, not how often samples land in HealthKit, and HealthKit’s background delivery rules are unchanged [3][21]. Expect denser samples to arrive in larger batches on the same wake schedule. Plan for batch size, not lower latency. What else changed is in Apple’s September health announcements.
Which step is it
Run these in order. Stop at the first no.
Both routes
- Which route is this user’s data on? A HealthKit or Health Connect read in your app, or a vendor OAuth connection. If you ingest both, check which one the missing data should have come from.
- Are the watch and phone connected? Bluetooth on, devices near, no red disconnected icon on Apple Watch [1] [2]. Ask when the vendor app last synced.
On-device route
- Is the sample in the on-device store? iPhone: Health > the metric > Data Sources. Android: Health Connect > the app’s permissions, and the type itself. If it is absent, your backend is idle and correct.
- Did the vendor app write Health or Health Connect? Garmin: Connect > Connected Apps, and the matching toggle on the other platform. The iPhone toggle does not enable the Android write.
- Is background delivery actually on? iOS: entitlement present,
enableBackgroundDeliverysucceeded, observer created at launch, completion handler called on every path including errors [3] [4]. If step count was requested as immediate, expect hourly anyway. - Did you burn the three strikes? If background updates used to work and then only foreground works, assume the handler was skipped until you have logs that say otherwise [4].
- Android: which grants are missing? Type, history, background.
getGrantedPermissions, not a guess [7]. Then check battery optimization for your app and for Health Connect [8]. - Is the changes token still valid? Older than the last successful sync by weeks, or an expiry error, means resync from a timestamp. Do not poll the dead token [6].
Cloud route
- 401 or empty 200? 401 is re-consent. Empty 200 is either no data yet or a sync that has not reached the vendor’s cloud.
- Did the webhook arrive? Check your delivery logs for the event. If it never came, poll once before you file a vendor incident. If it came and you returned an error, the vendor’s retry schedule is now your latency [16] [17].
Then
- Only then look at your own timing. If the sample reached you and it is still late, the latency you measure here is yours.
The teams who skip to step 11 spend a week optimizing a queue that was never handed a row.
References
-
Apple Support. About Bluetooth, Wi-Fi, and cellular on your Apple Watch. The Watch uses Bluetooth when the iPhone is near, because that conserves power, and tries Wi-Fi when Bluetooth is unavailable. Retrieved 21 September 2026. https://support.apple.com/en-us/109319
-
Apple Support. If your Apple Watch isn’t connected or paired with your iPhone. A red iPhone icon means the link is down; bring the devices together. https://support.apple.com/en-us/108360
-
Apple.
enableBackgroundDelivery(for:frequency:withCompletion:). Frequency is a maximum; some types including step count are capped at hourly; the background-delivery entitlement is required on iOS 15+; observers should be registered at launch; Simulator does not support background server queries. https://developer.apple.com/documentation/healthkit/hkhealthstore/enablebackgrounddelivery%28for:frequency:withcompletion:%29 -
Apple.
HKObserverQueryCompletionHandler. Call the block when processing finishes. If you do not, HealthKit backs off, and after three failures it stops background updates. https://developer.apple.com/documentation/healthkit/hkobserverquerycompletionhandler -
Apple Developer Forums. Clarification on HealthKit observer delivery frequency. Apple developer support: the requested frequency is not guaranteed; background-execution budget, battery level, and competing apps can delay or skip wakes. https://developer.apple.com/forums/thread/823699
-
Android Developers. Synchronize data with Health Connect. Apps cannot be notified of new data. Foreground reads are the default. Background read is a separate grant. An unused changes token expires within 30 days. Guide last updated 8 September 2026. https://developer.android.com/health-and-fitness/health-connect/sync-data
-
Android Developers. Health Connect data types: additional read permissions.
READ_HEALTH_DATA_IN_BACKGROUNDandREAD_HEALTH_DATA_HISTORYare declared separately from per-type permissions. https://developer.android.com/health-and-fitness/health-connect/data-types -
Google. Troubleshoot Health Connect and send feedback. Data has not synced when the connected app lacks permission, does not support the type, or the user never started the sync. https://support.google.com/android/answer/13770384
-
Apple. Authorizing access to health data. Read denial is not distinguishable from an empty store;
authorizationStatus(for:)reports share permission. https://developer.apple.com/documentation/healthkit/authorizing-access-to-health-data -
Garmin. Health API. Summaries are delivered after the user syncs to Garmin Connect. https://developer.garmin.com/gc-developer-program/health-api/
-
Garmin Customer Support. Sharing Your Garmin Connect Data With Apple Health. https://support.garmin.com/en-US/?faq=lK5FPB9iPF5PXFkIpFlFPA
-
Android Developers. Get started with Health Connect. Sample schedules a one-hour periodic worker when
FEATURE_READ_HEALTH_DATA_IN_BACKGROUNDis available. https://developer.android.com/health-and-fitness/health-connect/get-started -
Android Developers. Optimize for Doze and App Standby. Background work is deferred when the device is stationary, screen off, and unplugged. https://developer.android.com/training/monitoring-device-state/doze-standby
-
Android Developers. Read data in Health Connect. Default history is about 30 days before the first grant unless
READ_HEALTH_DATA_HISTORYis granted. https://developer.android.com/health-and-fitness/health-connect/read-data -
Apple. Background Execution. In most cases the system does not relaunch an app after the user force-quits it. The user must launch the app, or reboot, before automatic background launch resumes. https://developer.apple.com/library/archive/documentation/iPhone/Conceptual/iPhoneOSProgrammingGuide/BackgroundExecution/BackgroundExecution.html
-
Google. Webhook subscriptions, Google Health API. Notifications for more than 20 data types, batched up to 99 messages, retried with exponential backoff for up to seven days. Retrieved 24 September 2026. https://developers.google.com/health/webhooks
-
WHOOP. Webhooks, WHOOP for Developers. Event types, payload, five retries over about one hour, respond within one second. Retrieved 24 September 2026. https://developer.whoop.com/docs/developing/webhooks/
-
Oura. Oura API Documentation, version 2.0, including webhook subscriptions. Retrieved 24 September 2026. https://cloud.ouraring.com/v2/docs
-
Samsung Developer. Health Connect FAQ to access Samsung Health data. Samsung Health writes to Health Connect as soon as data is created or changed once linked; Galaxy Watch to phone sync follows its own policy. Retrieved 24 September 2026. https://developer.samsung.com/health/health-connect-faq.html
-
Google Health Community. Clarification on Fitbit Web API and OAuth availability until September 2026 shutdown. https://support.google.com/googlehealth/thread/439040688/clarification-on-fitbit-web-api-and-oauth-availability-until-september-2026-shutdown
-
Apple. Introducing Apple Watch Series 12, with the all-new Health Sensing System. Apple Newsroom, 9 September 2026. https://www.apple.com/newsroom/2026/09/introducing-apple-watch-series-12-with-the-all-new-health-sensing-system/