Resolving Sync Conflicts in an Offline-First Workout Log
Updated July 27, 2026
The design decision is to carry two conflict strategies, not one, and to branch between them on a provenance flag that both mobile health platforms already hand you. Device-sourced samples are a dedupe problem: the device is authoritative, there is no semantic disagreement to settle, and the only question is whether you have already seen this measurement. Records a human typed — sets, reps, weights, meals — are a merge problem, and last-write-wins on those is usually wrong, because the loser was a real person's deliberate input and nothing in your app will ever tell them it went missing.
Gyms are the reason this matters more here than in most product categories. Basements, concrete, no signal, and — unlike almost any other offline scenario, where "offline" means reading cached content — the user is actively entering data the entire time they are down there. A 50-minute session can generate forty writes with zero connectivity, on two devices, and then flush them in an order that has nothing to do with the order they happened.
Here is the failure that gets reported as a bug: a user finishes set 3 on their watch, still offline. Their phone, which had the same session open and had reconnected, edits the weight on set 1 because they miscounted the plates. The watch flushes twenty minutes later carrying its whole view of the session, which is newer by arrival time, so it replaces the phone's. The corrected weight is gone. Nothing errored, nothing retried, no alert fired. The user notices three weeks later that a personal record they remember setting is not in their history, and there is no merge-conflict UI in a fitness app to explain why.
The split that the whole design hangs on
Both platforms model the distinction in the schema, which is a strong signal it is the right seam.
Google documents three recording methods on Health Connect's Metadata: RECORDING_METHOD_AUTOMATICALLY_RECORDED for data a device captured without the user starting anything, RECORDING_METHOD_ACTIVELY_RECORDED for a session the user deliberately started on a device, and RECORDING_METHOD_MANUAL_ENTRY for data the user typed — Google's own examples being nutrition and weight. Apple documents the same idea more coarsely as HKMetadataKeyWasUserEntered, a key indicating whether the sample was entered by the user.
The single most consequential thing you can do with that flag is not drop it at the adapter boundary. It is tempting to normalise everything into one samples table because "a sample is a sample". It is also how you end up with one conflict strategy applied to two problems that do not resemble each other.
| Device-sourced samples | User-entered records | |
|---|---|---|
| What it is | A measurement of something that happened | A claim about what the user did |
| Who is authoritative | The device | The person, and only the person |
| The real question | "Have I seen this measurement before?" | "How do these two intentions combine?" |
| Right primitive | Stable external id plus monotonic version | Append-only events, folded into state |
| Cost of getting it wrong | Doubled step or calorie total | A set the user did is silently deleted |
| Can you ask the user? | No — they cannot arbitrate their own sensors | Yes, and sometimes you must |
Two caveats before you branch on that flag in production. Apple's HKMetadataKeyWasUserEntered is writer-set, so its absence proves nothing — a third-party app that never sets it produces manual entries that look like device samples. Google made recordingMethod mandatory when constructing Metadata only from Jetpack 1.1.0-alpha12, so older records may not carry it either. Model provenance as three states — device, user, unknown — and never fold unknown into device.
Device samples: dedupe, and stop
For anything the device produced, you do not need any of the machinery below. Apple documents that saving an HKObject with HKMetadataKeySyncIdentifier makes the system look for existing objects with the same identifier and replace the old one when the new object's HKMetadataKeySyncVersion is greater. Google documents the mirror image: insertRecords upserts on clientRecordId, and the data with the highest clientRecordVersion takes precedence. That is a versioned upsert, and it is the correct shape for a measurement.
Note the sharp edge on the Android side: Google documents that if the incoming version is not higher, the upsert is ignored — which makes replays free, and makes a genuine correction shipped with a stale version silently vanish. Versions must be monotonic and assigned by something that knows the history, not by the client that happens to be writing.
The genuinely hard part of the device side — two honest devices measuring the same hour and you must not sum them — is overlap, not duplication, and it is a different page: see deduplicating health data across sources. Nothing on this page will help you there.
Why last-write-wins loses
LWW imposes a total order on operations that were not totally ordered, and then destroys everything that is not the maximum. Three specific ways that goes wrong for user-entered fitness data:
It is whole-record when the edits were field-level. A watch appends a set while a phone corrects a weight on a different set. Neither edit touches the other's field. Whole-record LWW still throws one away.
Arrival time is not event time. A phone that was offline flushes a backlog on reconnect while live events keep arriving, so out-of-order arrival is the normal case in a gym, not the exception. "Latest by received_at" reliably overwrites fresh data with stale data.
Client clocks are untrusted. Phones travel, drift, and get set by hand. If you order edits by the client's own clock, a user with a wrong clock can reorder their own history — and you will never see it as an error.
The fix for the last two is not to pick a better clock. It is to record both and give them different jobs: client time carries the health semantics (when the workout happened, and which civil day it belongs to, which is its own problem), and server receive time carries the ordering semantics for anything you must break a tie on.
An event log fits set-logging naturally
A set is not a cell to overwrite. A set is a thing that happened, at a time, on a device — an event. Modelling it as a mutable row means every offline device holds a competing copy of the same row and sync becomes a fight. Modelling it as an event means two offline devices produce a union of events, and the current state of a workout is a fold over them.
| Mutable rows | Append-only events | |
|---|---|---|
| What a set is | A row you UPDATE | An immutable fact plus later corrections |
| Two offline devices produce | Two versions of one row | Two disjoint sets of events |
| A "conflict" is | Any concurrent write | Only: same target, same field, causally concurrent |
| Deleting | DELETE, which an offline device will re-sync | A tombstone event |
| "The app deleted my PR" | Unanswerable | Answerable — you have the log |
| Cost | Low | Storage growth; you need snapshots and compaction |
A schema sketch. This is illustrative, not production-ready — no indexes, no partitioning, no retention.
-- One row per thing the user did or corrected. Never UPDATEd, never DELETEd.
CREATE TABLE workout_events (
event_id UUID PRIMARY KEY, -- generated ON THE CLIENT
user_id UUID NOT NULL,
session_id UUID NOT NULL, -- also client-generated
device_id TEXT NOT NULL, -- which client emitted it
seq BIGINT NOT NULL, -- per-device monotonic counter
kind TEXT NOT NULL, -- set_logged | field_set | set_deleted | session_ended
target_id UUID, -- the set this event is about
field TEXT, -- 'weight_kg' | 'reps' | 'rpe' | NULL
value JSONB,
base_event_id UUID, -- last event this device had applied for target_id
occurred_at TIMESTAMPTZ NOT NULL, -- CLIENT clock: health semantics
occurred_offset_minutes INT NOT NULL, -- zone offset in effect on the client
received_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- SERVER clock: ordering only
UNIQUE (device_id, seq)
);
Three deliberate choices in there.
event_id is generated on the client. An offline-created record needs identity before the server has ever heard of it — otherwise the client cannot reference it in a later edit, cannot deduplicate its own retries, and cannot show the user a stable row. The same UUID then doubles as your sync idempotency key, so a flush that times out and retries is a no-op rather than a duplicate set. It also travels: Apple documents HKMetadataKeyExternalUUID for exactly this ("if you want to add your own unique ID, add it to the object's metadata"), and Health Connect's clientRecordId is described as an optional unique ID your app supplies to reference records in your own datastore. One identifier, generated once, on the device, offline.
If you do write back to the platform, filter your own writes on the way in. Google's sync guide has you compare change.record.metadata.dataOrigin.packageName against your own package name for precisely this reason; without it, write-then-read-back is an infinite duplication loop.
Events are field-scoped. field_set with one field, not set_edited with a payload blob. This is what collapses "concurrent edit" from a common event to a rare one — most concurrent activity in a single-user session touches different fields or different sets, and field scoping makes that a clean merge instead of a coin toss.
base_event_id is your concurrency detector. Each event records the last event that device had already applied for that target. If two edits are causally ordered, one descends from the other; if they share a base, they are concurrent. This is a version vector collapsed to one entity and a depth of one, and for a single user with two or three devices it is all the detection you need.
Deletes are tombstone events, never row removals. Delete the row and an offline device will cheerfully re-sync the meal you just erased on its next connect. This is the same reason Apple exposes HKDeletedObject at all.
A worked two-device conflict
One user, one bench session, phone plus watch, basement gym. Client times on the left.
18:02 phone e1 set_logged A (bench, 60.0 kg x 8)
18:05 watch e2 set_logged B (bench, 60.0 kg x 8)
18:09 phone e3 field_set A.weight_kg = 62.5 base=e1
18:11 watch e4 set_logged C (bench, 62.5 kg x 6)
phone reconnects 18:12 and flushes e1, e3
watch reconnects 19:40 and flushes e2, e4
server received_at order: e1, e3, e2, e4
Folded, the session is three sets: A at 62.5 kg x 8, B at 60 kg x 8, C at 62.5 kg x 6. No decision was required of anybody. The events are disjoint, the edit descends from the set it edits, and arrival order is irrelevant.
Now run the same session against mutable rows. The watch's flush at 19:40 carries its view of the session — sets [B, C] — with the newest arrival time, and replaces the phone's [A(62.5), B]. Set A disappears. That is the bug from the top of this page, and note what is not present: no error, no retry, no alert. The pipeline is behaving exactly as designed.
The event log does not magically solve everything. Change e4 to a genuine collision:
18:11 watch e4' field_set A.weight_kg = 65.0 base=e1
Now e3 and e4' share a base, touch the same target and the same field, and disagree. That is a real conflict and no amount of schema removes it.
// Field-scoped resolution. `concurrent` means: neither event descends
// from the other (their base_event_id chains do not overlap).
function resolveField(a: FieldEvent, b: FieldEvent): Resolution {
if (!concurrent(a, b)) return { value: descendantOf(a, b).value };
if (a.value === b.value) return { value: a.value }; // agreed anyway
return {
value: laterByClientClock(a, b).value, // provisional, shown as-is
alternative: earlierByClientClock(a, b).value,
needsReview: true, // surfaced to the user later
};
}
// Never auto-resolve a delete against a concurrent edit.
// An edit says "this set happened and the number was wrong."
// A delete says "this set did not happen." Those are different claims.
if (concurrent(deleteEvent, editEvent)) {
return { state: "kept", needsReview: true, reason: "delete_vs_edit" };
}
LWW, vector clocks, CRDTs — and the honest cost of each
| Approach | How it works | Where it breaks | When we would justify it |
|---|---|---|---|
| Last-write-wins | One winner per row or field, by timestamp | Silently destroys the loser; whole-record LWW also destroys uncontended fields; needs a clock you do not have | Fields where a later value genuinely supersedes an earlier one and losing the earlier one costs nothing — display name, units preference, a UI toggle. Not sets. Not meals. |
| Version vectors / vector clocks | Per-replica counters; comparison tells you causal versus concurrent | Buys detection, not resolution — you still have to decide what a concurrent pair means, which was the hard part. Bookkeeping grows with device count | Many independent writers where you genuinely cannot enumerate them. In a single-user, two-device system a base_event_id gets you the same detection for a fraction of the machinery |
| CRDTs | Merge defined to be commutative, associative and idempotent, so any application order converges | Real cost: a library dependency, a different data model, metadata that inflates payloads, and semantics you must live with (concurrent inserts interleave; add-wins versus remove-wins is a decision, not a default) | Genuinely concurrent multi-writer objects — a coach and a client editing the same training plan, a shared household meal log. Not one person's own set log |
| Append-only event log | State is a fold over immutable, client-identified events | Not a resolver on its own; concurrent same-field edits still need a rule. Storage grows, so you need snapshots and compaction | Our default for workout and nutrition logging: the entities are small, bounded and single-user, which is exactly where a log gets most of a CRDT's benefit for a fraction of its complexity |
That last row is our engineering judgement about this problem shape, not surveyed industry practice. Do not adopt it because a page said so; adopt it because your entities are small, bounded and single-user, and stop if they are not.
When you cannot resolve it: ask
Some conflicts are genuinely undecidable from the data, and the correct behaviour is to surface them rather than pick silently. A few rules we would hold to:
- Never ask about device samples. The user cannot arbitrate whether their watch or their phone counted a flight of stairs better, and asking implies they should be able to.
- Ask only when both branches contain deliberate human input that cannot coexist. Two different weights on the same set. An edit racing a delete. A meal logged twice on two devices with different portions.
- Do not interrupt the session. No modal at set 4. Park it on the record, and offer "2 entries to review" in the post-session summary.
- Default to keeping more, not less. A duplicate set the user deletes costs one tap. A deleted PR costs a support ticket and, more expensively, trust in the log.
- Give the same treatment to implausible manual entries. A 100,000-step day is usually a fat-fingered extra zero, a unit confusion, or double counting — not a sensor fault. That belongs to the user as "confirm this entry?", not to you as a pipeline alert. See data quality monitoring for where that check lives.
What we would actually build
Two stores, deliberately not one.
raw_samples for device-sourced data: append plus tombstone, keyed on the provider identifier with a monotonic version, partitioned by time — and if you do partition by time, PostgreSQL forces the partition key into that unique constraint, which is a real hole rather than a formality, with rollups recomputed rather than incremented. The design considerations there are in time-series storage for health data.
workout_events for user-entered data: the schema above, field-scoped, client-identified, folded into a materialised current-state table that you can always rebuild from the log. A needs_review flag on the folded row, and a real place in the UI for it.
Provenance as a first-class column on both, with three values, carried from recordingMethod and HKMetadataKeyWasUserEntered all the way down and never collapsed. Client time and server time both stored, with different jobs. Tombstones everywhere. No vector clocks, no CRDT library, and no bespoke merge engine until you ship a genuinely multi-writer object such as a shared coach-client plan.
The honest limits
This design does not fix deletions coming inward from the platforms, and those are worse than the conflicts above. Apple documents that deleted objects are temporary and the system may remove them from the HealthKit store at any time to free space, with observer query plus background delivery as the only documented path to reliable deletion notification — so a client that is offline long enough may never learn a sample was deleted, and your server keeps data the user believes they erased. Google documents that a DeletionChange carries only the record id and not the type, for privacy, which means you must have stored the Health Connect metadata.id at insert time or the deletion is unactionable. Both are schema decisions made months before the first deletion arrives.
Long offline periods interact badly with delta sync. Google documents that an unused Changes token expires within 30 days; the offline-heavy user is precisely the one who trips it. Plan the fallback before you need it — see incremental sync.
An event log makes erasure harder, not easier. A right-to-erasure request now has to reach the log, every snapshot, every replay artefact and every rollup derived from them, not just a row. Budget for that up front: data deletion and export.
A wrong device clock still corrupts semantics, just not ordering. Server receive time protects your merge order. It does nothing about a workout the user's phone believes happened on the wrong day.
Nothing here has a documented performance envelope. No throughput, latency, or storage-ratio number in this space is something we have verified, and neither Apple nor Google publishes one for these paths. Design so you degrade gracefully rather than against a figure.
And the build-versus-buy line runs straight through the middle of this page. The device half — connecting Fitbit, Garmin, Oura, Whoop, normalising their schemas, deduplicating overlapping sources, keeping tokens alive — is a product other people sell, and buying it is frequently the right call. Health data aggregator APIs exist for exactly that — what a health data aggregator is covers the category if it is new to you — and a small team that builds it in-house usually spends a quarter rediscovering things a vendor already handles. What no aggregator will do is log your users' sets, reps and meals: that is your own domain data, produced by your own client, in a basement with no signal. The offline event log is the part you build — and the same rules apply to nutrition entries feeding a calorie total as to sets, because a meal logged twice on two devices doubles a deficit calculation just as quietly as a duplicated set inflates a personal record.
Pick the split — dedupe for measurements, merge for intentions — before you write the first sync endpoint. Retrofitting it means rewriting both the client and the schema, and by then the data you lost is gone.
Frequently asked questions
- Is last-write-wins good enough for sets and reps logged offline?
- Usually not, and the reason is that it fails silently. Last-write-wins imposes a total order on operations that were not ordered and then discards everything except the maximum, so a set the user actually performed can disappear with no error, no retry and no alert. It is also whole-record when the edits were field-level: a watch appending set 3 and a phone correcting the weight on set 1 touch nothing in common, yet one of them still loses. Reserve last-write-wins for fields where a later value genuinely supersedes an earlier one and losing the earlier costs nothing, such as a display name or a units preference. A logged set is not one of those.
- Why is conflict resolution different for device samples than for data the user types?
- Because there is no semantic disagreement in a measurement. When a watch reports a heart rate, the device is authoritative and the only question is whether you have already stored that measurement, which is a deduplication question answered with a stable external identifier plus a monotonic version. Apple documents this as HKMetadataKeySyncIdentifier with HKMetadataKeySyncVersion, and Health Connect as clientRecordId with clientRecordVersion. A set the user typed is a claim about what they did, so two devices can hold two legitimate, different claims and you have to merge intentions rather than pick a version. Both platforms encode the distinction in the schema, through Health Connect's recording method constants and Apple's HKMetadataKeyWasUserEntered, and the mistake is dropping that flag at the adapter boundary because a sample is a sample.
- How should an offline workout record get an ID before the server has seen it?
- Generate a UUID on the client at the moment the record is created, and treat it as the record's real identity for the rest of its life. Without it the client cannot reference the record in a later edit, cannot recognise its own retried flush as a duplicate, and cannot show the user a stable row. The same identifier then doubles as your sync idempotency key, so a flush that times out and retries is a no-op instead of a second set. It also travels into the platforms: Apple documents HKMetadataKeyExternalUUID for adding your own unique ID to an object's metadata, and Health Connect's clientRecordId is described as an optional unique ID your app supplies to reference records in your own datastore.
- Do I need CRDTs for a fitness app, or is an append-only event log enough?
- For one user logging their own sets and meals, an event log is our default and CRDTs are usually more machinery than the problem needs. The entities are small, bounded and single-user, which is the case where folding immutable events gets you most of a CRDT's benefit for a fraction of its cost. CRDTs bring a library dependency, a different data model, metadata that inflates payloads, and semantics you have to live with such as interleaved concurrent inserts and an add-wins versus remove-wins choice. Where we would reach for one is a genuinely concurrent multi-writer object, such as a coach and a client editing the same training plan. Vector clocks sit awkwardly in between: they buy you detection of concurrency, not resolution, and in a two-device system you can get the same detection by recording on each event the last event that device had already applied.
- When should a sync conflict be shown to the user instead of resolved automatically?
- Only when both branches contain deliberate human input that cannot coexist: two different weights on the same set, an edit racing a delete, or the same meal logged twice with different portions. Never surface a device-sample conflict, because the user cannot arbitrate whether their watch or their phone counted a flight of stairs better and asking implies they should be able to. Do not interrupt the session with a modal either; flag the record and offer a short review list in the post-session summary. Default to keeping more rather than less, since a duplicate set costs one tap to remove while a deleted personal record costs a support ticket and trust in the log. The same treatment fits an implausible manual entry, such as a hundred-thousand-step day that is almost certainly an extra zero: ask the user to confirm it rather than raising a pipeline alert to yourself.
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