For a workout-tracking app, reliably saving workout data is the whole point. A bad internet connection or a server outage shouldn’t prevent a workout from being recorded.
So I started thinking about how to make workout logging reliable even when the server is down or the internet connection is unstable.
Local-First Storage
The problem was simple: saving a workout still depended on the network. So I decided to store the data in a local database first.
- The app reads from and writes to the local database.
- A workout is considered saved as soon as it is written to the local database.
- The app syncs those changes to the server in the background.
But this created a new problem. The app still needed user accounts and a server to sync workout logs across devices, but those devices could fall out of sync when each one modified its local copy.
For example, suppose the server stores a program called Push Day, and two devices each have a local copy. If one device renames it Push A and the other renames it Push B, the two copies now disagree.
This happens because an offline-first system keeps multiple replicas of the same data. A replica is a local copy of shared data, and each node can update its own copy independently.
The problem, then, is how to make multiple replicas converge on the same state.
Eventual Consistency
According to the CAP theorem, a distributed system cannot guarantee both consistency and availability when a network partition occurs. Because my product is offline-first, I chose availability, which means allowing replicas to become temporarily inconsistent. If I prioritized consistency instead, the app would have to reject writes while the partition lasted, defeating the purpose of offline-first.
To make those replicas converge, I chose eventual consistency. Eventual consistency means that replicas may temporarily disagree but will eventually converge on the same state.
That alone was not enough, though. Without a deterministic merge policy, the final state could depend on the order in which requests reached the server:
- Device A renames
Program1toProgramA. - Device B then renames its copy of
Program1toProgramB. - Device B’s request reaches the server first, so the server updates the program to
ProgramB. - Device A’s request arrives later and overwrites it with
ProgramA. - An older change has now overwritten a newer one.
Optimistic Concurrency Control
To prevent this, I chose optimistic concurrency control (OCC). Each resource has a version number, and the server applies a change only if the request’s version matches the resource’s current version.
The server compares the request’s version with the resource’s current version. If the versions do not match, it returns a conflict instead of applying the change.
- The server applies Device A’s change to
ProgramAand increments the resource’s version to11. - It rejects Device B’s request as a conflict because the request is still based on version
10.
v_current_version := public.sync_current_resource_version( v_user_id, v_resource_type, v_resource_id);
if (v_change_kind = 'create' and ( v_base_version <> 0 or v_current_version <> 0 )) or (v_change_kind <> 'create' and ( v_current_version = 0 or v_base_version <> v_current_version )) then v_conflicts := v_conflicts || jsonb_build_array(jsonb_build_object( 'resourceType', v_resource_type, 'resourceId', v_resource_id, 'version', v_current_version ));end if;The trade-off is that resolving a conflict can be relatively expensive because it may require repeated pulls, merges, and retries. I accepted that cost because users rarely log the same workout on multiple devices at the same time, so I expected conflicts to be uncommon.
Conflict Resolution
Detecting a conflict with OCC is only the first step. The client still has to resolve it according to the product’s merge policy, which follows a few simple rules:
- When OCC detects a version conflict, the server returns a conflict response.
- The client compares the resource’s latest server state with its local state to identify which fields have changed.
- If the local and server changes affect different fields, the client merges them, updates the request to use the latest version, and retries.
- If both changes affect the same field, the client pulls the latest server state, resolves the conflict according to the merge policy, and retries the request.
OCC detects conflicts, while the merge policy determines how they are resolved. Together, they bring the replicas back into sync.
private async runIncremental(): Promise<SyncOutcome> { let pull = await this.pullClient.pullChanges(); let push = await this.pushClient.pushPendingOperations();
while (push.conflict) { pull = mergePullOutcomes(pull, await this.pullClient.pullChanges()); push = mergePushOutcomes( push, await this.pushClient.pushPendingOperations(), ); }
return { status: "completed", pull, push };}Limitations
But this approach still has clear limitations.
Synchronization Is Not Immediate
First, changes are not synchronized immediately. The server does not push updates to every client, so a client cannot know that another device has changed the data until its next sync. A sync runs when the app starts, returns to the foreground, reconnects to the network, or records a local change. Each sync pulls server changes before pushing the local queue. If the push encounters a conflict, the client pulls again, merges the changes, and retries.
If two devices keep updating the same data concurrently, conflicts become frequent, and the repeated retries make OCC expensive.
One possible solution is change data capture (CDC). CDC detects database changes and sends them as events to other systems, making near-real-time synchronization possible.
However, delivering those events to clients requires an additional channel such as WebSocket or server-sent events (SSE), which adds implementation and operational costs. Since users rarely log the same workout on multiple devices at once, I decided not to add that complexity.
When a Newer Change Arrives Late
Second, a newer change may be rejected if another device has already updated the resource by the time it reaches the server.
- Device A makes a change at 10:25, and its request reaches the server first.
- Device B makes a change at 10:26, but its request reaches the server later because the device was offline.
Under my conflict policy, Device B’s change should win because it occurred later. However, Device A’s request has already incremented the resource’s version, so Device B’s request is rejected with a stale version.
To handle this, I record when each local operation occurred. After Device B’s request is rejected, the client pulls the latest server state and compares the timestamps of the server-applied change and the pending local change. If both affect the same field, it keeps the change with the later timestamp, merges the result, and pushes again. This allows the more recently recorded change to win regardless of arrival order.
Local timestamps become unreliable if the user changes the system clock. I could reduce this risk by combining the last observed server time with a monotonic clock or by using a hybrid logical clock. Neither approach can perfectly recover the real-world order of changes made on disconnected devices, and concurrent editing is rare in this product, so I decided against adding that complexity.
Logging Comes First
The CAP theorem says that a distributed system cannot guarantee both consistency and availability during a network partition, so the product needs a clear priority. A delay of a few seconds before another device sees a change is relatively cheap. Concurrent edits to the same data are unlikely, and real-time editing is not a core requirement. Losing a workout record, however, is far more costly.
These trade-offs led me to prioritize reliable logging over strong consistency. If I later add independent workout tracking on a wearable device, that assumption may no longer hold, and I will revisit the architecture then.