Back to blog
iOS

HealthKit Sync Gone Wrong: Timezones, Duplicates, and Data Integrity

May 24, 20259 min read

The Day a Patient Had 240 Heart Rate Readings That Never Happened

A doctor at one of our clinics flagged it during a review. Her patient's chart showed hundreds of heart rate samples clustered around timestamps that matched nothing the patient remembered doing. He hadn't run a marathon. He had synced his Apple Watch, reinstalled our app a couple of weeks later, and synced again. On our side it was a data integrity bug in the HealthKit sync pipeline that polluted a clinical record a physician was using to make decisions.

When a shopping app duplicates an item in a cart, someone gets annoyed. When a health app duplicates a patient's vitals, a doctor can read a pattern into a chart that was never there.

How the Sync Was Supposed to Work

We used an HKObserverQuery to get notified when new samples of the types we cared about (heart rate, blood pressure, steps, weight, blood glucose) appeared in HealthKit. We registered for background delivery so the OS would wake us even when the app wasn't open. For reading we used HKAnchoredObjectQuery, the right tool for incremental sync: you give it an anchor for everything you have already seen, and it hands back only what's new, plus a fresh anchor for next time.

The anchor is the whole game. HKAnchoredObjectQuery returns added and deleted samples relative to it and gives you an updated anchor to persist. Save it, pass it in next time, and you get a clean delta: you never see the same sample twice and you always learn about deletions.

In practice, we broke most of those assumptions.

Duplicates: The Anchor That Kept Resetting

We were persisting the anchor, but in a way that didn't survive an app reinstall. Worse, on launch, if we failed to deserialize the stored anchor, we quietly fell back to a nil anchor and queried everything from the beginning of time.

A nil anchor tells HKAnchoredObjectQuery you have never seen anything and want the entire history. Every time that fallback triggered, we pulled the patient's whole HealthKit history again and posted it to the backend as new. The backend keyed records on our own generated identifiers rather than anything stable from HealthKit, so it inserted a second copy of everything.

That's how one patient ended up with 240 heart rate readings that were really 80 real readings counted three times. Nothing was fabricated; we were importing real data we had already imported, because nothing in our pipeline recognized a sample it had seen before.

Timezones: The Sample That Happened at the Wrong Hour

The second bug was subtler. Some blood glucose samples were showing up at the wrong time of day.

HealthKit samples carry a start and end date as absolute points in time, and we handled that fine. But a glucose reading means different things depending on when it was taken relative to a meal: a fasting reading in the morning and a reading after dinner tell different clinical stories. HealthKit stores an optional metadata key for the sample's timezone, and we ignored it. We took the absolute timestamp and rendered it in the backend's default timezone, which was not always the patient's.

For a patient who traveled, or whose device was set to a different region, a fasting reading taken at 7am local time could render as an afternoon reading. The number was right, the clinical meaning was wrong. A doctor reading that chart draws the wrong conclusion, because we stripped the context that made the number interpretable.

Units and Deletions: Two More Ways to Be Wrong

  • Unit mismatches: HealthKit lets you read a quantity in whatever unit you ask for, and different sample types have different canonical units. We had a spot where body weight was read in one unit but stored under a field the backend assumed was another. Most patients never noticed because the numbers were plausible either way, which is the kind of wrong that survives review.
  • Deletions not propagating: When a patient deleted a bad reading in Apple Health, say a blood pressure measurement they had taken wrong, that deletion never reached our backend. `HKAnchoredObjectQuery` was handing us the list of deleted samples; we weren't doing anything with it. The record the patient thought they had erased lived on in their chart.

The Fix: Treat HealthKit's UUID as the Source of Truth

Across these bugs we were inventing our own identity for data that already had one. Every HealthKit sample has a stable UUID. Once we keyed on that, the rest fell into place.

  • Dedup by HealthKit UUID: We agreed with the server side to make the sample's HealthKit `UUID` the unique key. After that, importing the full history again was harmless: a sample we had already stored was recognized and ignored. This alone would have prevented the chart with 240 readings.
  • Persist the anchor correctly, and never silently reset it: We fixed the anchor storage so it survived reinstalls where appropriate, and removed the silent fallback to `nil`. If we can't trust the stored anchor, deduping on `UUID` makes a full query safe rather than catastrophic, and we no longer trigger one by accident.
  • Store both the sample's timezone and UTC: We now read the timezone metadata key off each sample and persist it alongside the absolute UTC timestamp. A fasting morning glucose reading now reads as morning no matter where the patient or the server is.
  • Honor deletions: We consumed the list of deleted samples from the anchored query and propagated them to the backend, so erasing a bad reading in Apple Health erases it from the clinical record.
  • Reconciliation: We had already corrupted real charts, so deduping going forward wasn't enough. We ran a reconciliation pass that grouped existing records by their HealthKit UUID and collapsed the duplicates; for older records that predated storing the UUID, we matched on type, value, and exact timestamp. We ran it in batches with clinical approval, and it cleaned up the mess already in the charts.

Why This One Mattered More

I've shipped plenty of bugs. Most are a bad screen or a crash. This one was different because the failure mode was invisible. Nothing crashed, nothing was logged, the data looked plausible, and a doctor was making real decisions on top of it.

Background delivery made it harder to catch. Syncs happened opportunistically across reinstalls and OS updates, so the duplication was intermittent and nearly impossible to reproduce on demand. We only understood it once we logged, for every synced sample, its HealthKit UUID and the anchor state at sync time. Seeing the same UUIDs arrive again after a reinstall made it obvious.

Key Takeaways

  • Use the source's identity, not your own: Every HealthKit sample has a stable UUID; keying on it instead of a generated ID makes repeated syncs idempotent and kills duplicates at the root.
  • The anchor is the whole contract: Persist the `HKAnchoredObjectQuery` anchor reliably and never silently fall back to `nil`, because a reset anchor means importing all of history again.
  • A timestamp without its timezone is a clinical hazard: Store both the sample's original timezone and UTC; the absolute number is meaningless without the context that makes it interpretable.
  • Deletions are data too: The anchored query hands you deleted samples for a reason; ignoring them lets erased records live on in a patient's chart.
  • Silent wrong is worse than loud broken: Clinical data bugs don't crash; instrument the pipeline so you can see identity and state per sample.

Read next

Chasing a Retain Cycle: The Video Call Screen That Leaked Memory

After a few back-to-back video consultations the iOS app's memory kept climbing until the OS killed it, so I went retain-cycle hunting.