Incremental Sync: Reading Only What Changed Since Last Time
Updated July 27, 2026
Sync only new health data by asking the platform for its own change cursor — an HKQueryAnchor on iOS, a changes token on Android — and then treat what comes back as a list of which days are now wrong, not as a list of values to add up. The constraint that forces this design is that health data is retro-edited: last night's sleep gets revised after further processing, a watch backfills a week late, a user corrects Tuesday's workout on Friday, and on iOS Apple's own condenser rewrites months-old workouts. So: let the cursor mark days dirty and recompute those days from raw data. Do not increment a running total.
The intuitive design is a timestamp watermark — store last_synced_at, ask the provider for everything since. It is the wrong one, and it is wrong in a way that produces a plausible-looking number rather than an error.
The failure a watermark produces
A user's sleep session ends at 06:40 on Tuesday. Your sync runs at 09:00, reads it, writes 6 h 20 m to Tuesday, and moves the watermark to 09:00. Tuesday evening, the provider finishes a second pass over the night and revises the session: different stage boundaries, 6 h 55 m of total sleep, same night. Wednesday's sync asks for everything since Tuesday 09:00. If your watermark is over event time, the revised session's start is Monday 23:45 — before the watermark — and you never see it. Tuesday stays at 6 h 20 m forever, and nothing in your logs is red.
That is the whole problem in one paragraph, and notice which axis it turns on. A watermark is a filter over some time column, and there are two candidates that behave completely differently:
- Event time (
startDate,time,startTime) — when the activity happened. A watermark here silently drops every late arrival and every retro-edit, which for health data is not an edge case. - Modification time — when the record last changed. A watermark here catches the revision, but only if the provider exposes such a field and keeps it honest, and it still tells you nothing about deletions: a deleted record is simply absent from the next response, and absence is indistinguishable from "outside my window".
Both platforms make the event-time version untenable by design. Apple documents that samples carry arbitrary caller-supplied dates, with retro-dating permitted all the way back to earliestPermittedSampleDate(); that users "can always modify their data outside your app"; that Watch-to-iPhone sync arrives after the fact; and — the one people never plan for — that HealthKit itself mutates history. stepCount sample data "may be condensed and/or coalesced by HealthKit", and the condensing process, which applies to workouts "at least a few months old", writes quantity series samples and then deletes the originals. Apple further documents that HealthKit "may condense samples for a given workout more than once … if the condenser algorithm changes." There is no value of N for which "records older than N days are immutable" is true on iOS.
On Android, Health Connect's insertRecords has upsert capability keyed on clientRecordId, so an existing record can be replaced in place by whichever app wrote it — and Google's own guidance is to prefer "changelog handling for data synchronization instead of relying on raw read requests."
If you are here because your dashboard is stale rather than wrong, that is a different problem with a different fix — see wearable data delayed.
Three sync models
| Model | How deletions surface | On loss or expiry | What it costs you |
|---|---|---|---|
| Timestamp watermark — store an instant, re-query for anything later | They do not. A deleted record is just missing, which looks identical to "outside the window". | Nothing breaks — widen the window and re-read. This is its one real advantage. | You must guess a lookback overlap, you miss retro-edits if you filter on event time, and you have no deletion channel at all. |
Opaque cursor — HealthKit's HKQueryAnchor, a position in the platform's own change log | First class: the anchored query's results handler carries a deletedObjects array alongside the samples. | You pass nil and the query replays the entire matching history as additions. Not an error — a documented state. | The cursor is uninspectable. You cannot compare two anchors, or ask how far behind you are. Your write path must be idempotent because replay is normal. |
Change token with a lifetime — Health Connect's, with an explicit hasMore pagination loop | First class as DeletionChange — but id-only, deliberately. | Google documents that an unused token expires within 30 days, surfaced as a flag on the response, and publishes a ranked fallback ladder. | A standing obligation: sync each user at least inside the window or lose the delta stream, and your fallback is bounded by a read window you may not have permission to exceed. |
The column that surprises people is the first one. Deletion detection is the reason cursors exist. A watermark cannot express "this row is gone", and health data gets deleted constantly — users prune bad GPS tracks, delete accidental workouts, and remove manual entries they mistyped.
HKAnchoredObjectQuery, precisely
An anchor is not a timestamp. HKQueryAnchor is an opaque class with no date property, no comparison operator and no ordering API. Its only non-coder initializer, init(fromValue:), exists purely to migrate pre-iOS-9 integer anchors — constructing one from a number you invented is not a supported operation. Apple's own phrasing at WWDC20 is "a specific point in time in the evolution of the health database", which is a version cursor, not a wall clock.
That distinction is the entire reason the anchor works where a date predicate does not. The anchor tracks when the store learned about an object, not the object's startDate. A sample saved today with last Tuesday's start date is newer than your anchor and gets delivered. You cannot substitute predicateForSamples(withStart: lastSyncTime, ...) and get the same result — you would be filtering on exactly the axis that retro-edits move. (Apple never states the precise internal ordering key in those words; treat "insertion order" as our inference, not documentation.)
Deletions arrive, but nearly blind. The results handler signature carries [HKDeletedObject]? as a first-class array, and HKDeletedObject exposes only uuid and metadata — no type, no dates, no value. So a deletion is unactionable unless you already persisted a mapping from uuid to (type, dates, value) at ingest. That is a schema decision you make months before the first deletion arrives. Two related traps:
- The query's
predicate"filters both the samples and the deleted objects returned by the query." A date-range predicate you added for performance will silently hide deletions of samples outside that range. - Apple documents that deleted objects are temporary and "the system may remove them from the HealthKit store at any time to free up space." The only documented path to at-least-once deletion delivery is an
HKObserverQueryregistered for background delivery, whose update handler then runs an anchored query — and anchored queries themselves cannot register for background delivery. That machinery belongs to background sync; what matters here is that a launch-time-only anchored query can permanently miss a deletion.
Durability across reinstall is undocumented. Apple documents only how to persist an anchor: HKQueryAnchor adopts NSSecureCoding, so you can archive it and reload it next launch. Apple says nothing about whether an anchor stays valid across app reinstall, device restore, or migration to a new device. We could not find a statement either way. Developers report anchors that round-trip through NSKeyedArchiver comparing unequal to the original, and report persisted anchors causing a query to return the whole store again — peer reports, not Apple's position.
So do not design around anchor durability. Design around idempotency on HKObject.uuid, so that a full replay is a no-op rather than a doubling. That is the backstop, and it is the thing you can actually verify in a test. (Whether a sample's uuid is itself stable across a device restore is also unverified; Apple's separate sync-identifier design exists precisely because a cross-ecosystem stable key was needed, which is weak evidence that uuid alone is not that key.)
Health Connect change tokens, precisely
The shape is a token you fetch, spend, and replace:
// Per record type — see below for why.
val token = healthConnectClient.getChangesToken(
ChangesTokenRequest(recordTypes = setOf(WeightRecord::class))
)
var next = token
do {
val response = healthConnectClient.getChanges(next)
if (response.changesTokenExpired) {
// NOT an exception. You must handle it yourself.
return recoverFromExpiredToken()
}
response.changes.forEach { change ->
when (change) {
// UpsertionChange carries the full record: metadata.id, metadata.lastModifiedTime.
is UpsertionChange -> markDirty(change.record)
// DeletionChange carries the deleted record's id and nothing else.
// Resolve it against your own id index; check the property name in the SDK.
is DeletionChange -> markDirtyByStoredId(change)
}
}
next = response.nextChangesToken
} while (response.hasMore)
Four things about that loop are load-bearing.
Expiry is a boolean, not a throw. Google documents that an unused changes token expires within 30 days, and the expiry is surfaced as changesTokenExpired on the ChangesResponse. The official codelab's own pattern is for the app to raise its own error from that flag (if (response.changesTokenExpired) { throw IOException(...) }). If you only wrote a try/catch, expiry passes straight through as an empty-looking successful sync. Separately, TOKEN_EXPIRED and TOKEN_INVALID appear in Google's Android 13-to-14 migration documentation, which notes that changelogs were not migrated across the APK-to-framework-module boundary; the class those two constants belong to did not render for us, so use the ChangesResponse flag as your check and treat the named constants as platform-migration signals.
On expiry, you owe a bounded re-read. Google publishes a ranked recovery ladder, most to least ideal: read and dedupe all data from your last-read timestamp or the last 30 days; read only since your last-read timestamp, accepting "some data discrepancies … around expiry time"; delete then re-read the last 30 days; or — explicitly labelled least ideal — read the last 30 days without deduping, which "results in duplicate data displayed to users". Take the first rung. It requires that your ingest is idempotent, which it should be anyway.
Two different 30-day numbers, one nasty interaction. The token lifetime is 30 days. Separately, "by default, all applications can read data from Health Connect for up to 30 days prior to when any permission was first granted", and extending that needs PERMISSION_READ_HEALTH_DATA_HISTORY — without it, "an attempt to read records older than 30 days results in an error." Those are the same number and completely different mechanisms. A token that expires on a user who never granted the history permission leaves you able to re-read only 30 days; anything older that changed in the meantime is unrecoverable. Worse, the read window is anchored to first permission grant and resets on reinstall. On Android 14 and higher an app has no historical limit reading its own data, which does not help you here, because the data you are syncing is someone else's.
Deletions are id-only and type-less by design. Google states this is a privacy decision: DeletionChange "only contains the id of the deleted record, and not the record type". So you either use one token per data type — which Google recommends twice anyway, so that a single revoked permission does not blow up the whole batch — or you maintain a global index from Health Connect metadata.id to (type, row). There is no third option. And you need that metadata.id persisted at ingest regardless: Google states flatly that if your app reads data from Health Connect, "you must store the id."
Two more, briefly: filter your own writes out of the change stream with change.record.metadata.dataOrigin.packageName != context.packageName, or write-then-read-back becomes an amplification loop. And Google does not publish Health Connect rate-limit numbers — only the categories, plus a note that background limiting is stricter than foreground. Design your loop to back off adaptively rather than to a guessed constant.
The pattern that makes all of this correct: dirty days, then recompute
Here is the part most pipelines get wrong even after they adopt a proper cursor. They take the samples the anchored query returned and add them into a daily total. That is broken for three independent reasons:
- Replay doubles it. The anchor can legitimately reset to
nil(reinstall, an anchor the store no longer recognises, a bug). An additive pipeline turns that into a user whose step count doubled overnight. - Raw samples are not merged. An Apple Frameworks Engineer states it directly: with
HKSampleQueryor the other non-statistics queries "you will have to select between overlapping samples within your app, and it is unlikely that you will be able to match HealthKit's merge algorithm correctly." Summing an anchored query's samples across a phone and a watch double-counts the overlap — Apple's own WWDC example of the problem. Source merging happens only insideHKStatisticsresults, and only for quantity types. See deduplicating health data for that whole fight. - Sample identity churns without the numbers changing. Condensing deletes originals and writes series samples; Apple asserts statistics are preserved across it, and asserts nothing about sample counts or UUIDs. A pipeline keyed on sample identity sees a storm; a pipeline keyed on daily statistics sees nothing.
Apple's own prescribed shape, from WWDC20's synchronization session, is the fix: "When the anchored object query updates you with changes in HealthKit, you can look at the dates of the returned samples and use that to create and run the statistics collection query. The statistics returned for those days can then be sent to the remote computer as updated graph data."
Read that as a rule. The cursor's job is to detect dirty days. A separate, authoritative read computes what those days now total. The delta feed is a change detector, never a data channel.
-- The queue the change feed writes into. Deliberately boring.
CREATE TABLE dirty_days (
user_id uuid NOT NULL,
provider text NOT NULL,
metric text NOT NULL,
local_date date NOT NULL, -- civil date, not a UTC bucket
enqueued_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, provider, metric, local_date)
);
-- Re-enqueueing the same day is free; that is the point.
INSERT INTO dirty_days (user_id, provider, metric, local_date)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING;
Note the two ends of that. Marking a day dirty is idempotent, so a replay is harmless. But when the worker writes the recomputed rollup, ON CONFLICT DO NOTHING is exactly wrong — it would pin a day to whatever partial value arrived first that morning. The rollup write is DO UPDATE, gated on a monotonic version. Different conflict rules at the two layers; conflating them is a common bug.
Three details that decide whether this works:
- A "dirty day" is a civil date, derived from the sample's own instant and offset — not a UTC bucket. A sample that straddles midnight dirties two days. Get this wrong and your recompute repairs the wrong cell; the reasoning is in timezones and day boundaries.
- Deletions dirty days you have to look up. Both platforms hand you an identifier and nothing else. Your
uuid-or-idindex is what turns "record 9f3c… was deleted" into "recompute 2026-07-14 for steps". - A recomputed rollup is a pure function of
(user, metric, day). That is the property worth paying for. "Is this number right?" becomes a question you answer by recomputing one cell, not by auditing the history of a counter. It also means the recompute path and the historical backfill path are the same code.
What we would actually build
Four pieces, on the client and the server respectively.
On device: one long-lived anchored query or changes-token loop per record type; persist the cursor locally; ship samples plus their identifiers to your API, never pre-aggregated totals. Resolve the civil date on the device, where the recording zone offset is knowable, and send it as a field.
On the server: a raw sample table with a uniqueness constraint covering the provider identifier so replays are no-ops — note that if you partition that table by time, PostgreSQL requires the partition key inside the constraint, so it becomes (provider, external_id, start_time) and stops being the guard you wanted; time-series storage covers the application-level step that closes the gap; a dirty_days queue; a rollup table recomputed from raw; and a per-(user, provider, record_type) row holding cursor state and — importantly — last_successful_sync_at, which is the thing you actually alert on. The cursor is opaque; your own last-success timestamp is not, and it is how you notice that a user's token is about to age out.
Two things we would add that are not in either platform's guidance:
- A periodic full-window reconciliation, not just delta sync. If a device is offline long enough that HealthKit purges its deleted-object records, that deletion is gone: the sample is no longer in HealthKit to be diffed against, and no server-side logic recovers it. A scheduled re-read of a bounded recent window with a set-difference against what you hold is the only thing that catches it. Run it monthly, not nightly.
- Back up the anchor, but never trust it. Persisting it server-side costs nothing and may save a full replay. Because its validity across reinstall is undocumented, the design has to assume a
nilanchor is a normal Tuesday.
The honest limits
This design does not make daily totals final — nothing does, and a page that claims otherwise is selling you something. It bounds the blast radius: a restated day restates cleanly instead of drifting. Related, if the definition of a metric changes on your side rather than the data changing on theirs, that is a different trigger for the same recompute machinery — see metric versioning and recompute.
What is genuinely not documented, and where we will not guess for you: whether an HKQueryAnchor survives reinstall or restore; the precise internal ordering key the anchor advances on; Health Connect's actual rate-limit numbers; and how a LocalDateTime-based TimeRangeFilter resolves against stored records. Each of those is a place to build a fallback rather than an assumption.
The structural limit is bigger than any of them: neither platform gives your server a change feed. Both cursors live on the user's device. Google states plainly that with Health Connect "your app can't get notified of new data", and Apple's background delivery is documented with an upper bound and no lower bound, plus a documented shutdown after three unacknowledged deliveries. Your backend's freshness is therefore bounded by how often the user's phone wakes up and cooperates, which is not something better engineering fixes. Where a provider does push to your server, what arrives is a change pointer delivered at least once rather than a cursor you hold, and that trade has its own failure mode — making repeated deliveries safe is the other half of this design.
When to buy this instead
If you are integrating more than two or three sources, an aggregator — Terra, Rook, Sahha, Vital and others — is often the right answer, and we would not talk you out of it. What you are buying is not "an API"; it is the removal of N incompatible cursor models. One webhook shape, one resource shape, and someone else holding the per-user cursor state, the token expiry clocks, and the id-to-type index each platform requires. That is months of the least interesting work you will ever do, and none of it differentiates your product. Start at health data aggregator APIs, and what a health data aggregator is if the category is new to you.
But be clear about what it does not remove. An aggregator does not make health data stop being retro-edited. Late-arriving and revised data still lands on your side, days you already computed still become stale, and you still need a dirty-day queue and a recompute path. Buying removes the cursor bookkeeping, not the restatement problem. Anyone who tells you otherwise has not run a backfill.
We would build it in-house in three cases: a single-platform iOS app, where the anchor is right there and an aggregator is a network hop plus a processor agreement for something the OS already does; a product whose value depends on raw sample-level provenance that the aggregator's normalized schema flattens away; or a compliance posture where routing health data through an additional processor is the expensive part. Outside those, buy it.
Start from integrating HealthKit or Health Connect for the platform mechanics, then come back and build the dirty-day queue before you write the first rollup — retrofitting it onto an incremental counter means recomputing everything anyway.
Frequently asked questions
- Why can't I just filter health samples by a last-synced timestamp?
- Because a watermark is a filter over a time column, and both candidate columns fail. If you filter on event time (the sample's start date), you miss every retro-edit and late arrival, which on health platforms is normal traffic rather than an edge case: Apple documents that samples carry arbitrary caller-supplied dates back to a platform floor, that users can edit their data outside your app at any time, and that HealthKit itself condenses and rewrites months-old workout data. If instead you filter on a modification time, you may catch revisions where the provider exposes such a field, but you still get no deletion signal at all, because a deleted record is simply absent from the response and absence is indistinguishable from being outside your window. A change cursor exists specifically to express deletions and re-surfaced history, which a timestamp cannot.
- Is an expired Health Connect changes token thrown as an exception I can catch?
- No. Google surfaces it as a boolean flag on the response, changesTokenExpired, and the official codelab pattern is for your own code to raise an error from that flag. If you wrote only a try/catch, an expired token passes through as a successful sync that happens to return nothing, which is the worst possible failure shape. Google documents that an unused token expires within 30 days and publishes a ranked recovery ladder: most ideal is to read and dedupe all data from your last-read timestamp or the last 30 days, and least ideal is to read the last 30 days without deduping, which the docs say results in duplicate data shown to users. Note that the constants TOKEN_EXPIRED and TOKEN_INVALID appear in Google's Android 13 to 14 migration documentation as platform-migration signals; the response flag is what your normal sync loop should check.
- Does an HKQueryAnchor still work after the user reinstalls my app?
- Apple does not document this either way. What Apple documents is only how to persist an anchor: HKQueryAnchor adopts NSSecureCoding, so you can archive it and reload it on the next launch. There is no published statement about validity across app reinstall, device restore, or migration to a new device, and developers report both anchors that compare unequal after a round trip through NSKeyedArchiver and persisted anchors that caused a query to return the entire store again. Design accordingly: assume a nil anchor and a full replay are normal states, and make your ingest idempotent on the sample UUID so that a replay is a no-op rather than a doubling. Backing the anchor up server-side is cheap and may save you a replay, but it cannot be the thing correctness depends on.
- Should I add up the samples an anchored query returns to get a daily total?
- No, and this is the mistake that survives adopting a proper cursor. Three reasons. A replay after an anchor reset would double the total. Raw samples are not merged across sources: an Apple Frameworks Engineer states that with a plain sample query you must select between overlapping samples yourself and are unlikely to match HealthKit's merge algorithm, so summing a phone's and a watch's samples double-counts the overlap. And sample identity churns without the numbers changing, because HealthKit's condensing deletes originals and writes series samples. Apple's own recommended shape from WWDC20 is to look at the dates of the returned samples, run a statistics collection query for those days, and send those recomputed statistics onward. The cursor is a change detector, not a data channel.
- How do I know which day a deleted sample belonged to?
- Only from an index you built yourself at ingest time, because neither platform tells you. Apple's HKDeletedObject exposes just a uuid and metadata: no type, no dates, no value. Health Connect's DeletionChange carries only the record id and deliberately omits the record type for privacy reasons. So you must persist a mapping from the platform identifier to the record's type, dates and value when you first read it, or a deletion is unactionable. On Android the alternative is to keep one changes token per data type, which Google recommends anyway so that one revoked permission does not break the whole batch. Two further traps on iOS: a date-range predicate on the anchored query filters the deleted objects as well as the samples, and Apple documents that deleted-object records are temporary and may be purged at any time to free space.
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