OdoKeep is now on the App Store. Download it free.

All articles

Offline sync

Keeping Offline Edits Safe in React Native with MMKV and Supabase

How an MMKV-backed sync queue handles in-flight deletes, lost insert responses and stale edits in a React Native app using Supabase.

Updated

On this page

A record that comes back after you delete it is a good way to make someone stop trusting a logbook.

That was one of the failure cases I had to address in OdoKeep, my vehicle tracking app. A user could create a record, delete it while the insert was still traveling, and see it again on the next sync. The local delete worked. The server insert worked. The queue's explanation of what had happened was wrong.

OdoKeep uses React Native and Expo, MMKV for local persistence, and Supabase for the server copy. The repositories write locally and enqueue the operation. Screens can use the local data while the sync manager deals with the network.

That makes saving a fill-up possible without a connection. It also means the queue has to describe more than a list of requests to try later.

A queued create is different from a traveling create

The first useful distinction was between an operation that had not been sent and one that had been sent but had not returned.

Consider an ordinary offline session:

Create record locally
Queue INSERT
Delete record locally
Cancel queued INSERT

There is no reason to contact the server. The record never existed there. Folding the create and delete into nothing is the correct optimization.

Now put a slow connection between the second and third steps:

Create record locally
Send INSERT
Delete record locally
Cancel queued INSERT
Server finishes INSERT
Next pull returns the record

Removing an operation from an array cannot recall a request already on the wire. The queue still called the create "pending", and the delete treated that word as proof that the server had never seen it.

The fix is a set of operation IDs currently in flight. In lib/sync-queue.ts, the part of coalescing that looks for a cancellable create now includes this condition:

const pendingCreate = pending.find(
  (op) => op.type === "create" && !this.inFlight.has(op.id),
);

If that create has already been sent, deleting the record must enqueue a server delete. It can no longer cancel the pair locally.

The set is deliberately held in memory. After a restart, the process has no live request to track. Persisting an inFlight flag would leave the next launch believing that an operation was still being sent, with nothing left to clear it. The durable queue survives; the description of this process's active requests does not.

This set also keeps an operation out of a second flush while the first request is outstanding. A sync triggered by reconnecting and another triggered by a refresh gesture must not send the same insert concurrently. The manager holds a guard around the flush, and the queue independently excludes in-flight operations from its due list. Even a user request that skips backoff still respects that exclusion.

Recovering an insert when the response disappears

There is another awkward case after an insert: the server may commit the row and the phone may lose the response.

On the next attempt, a plain insert can fail because the ID already exists. Treating that as a permanent rejection would roll back a local record that the server had successfully saved.

For the ordinary create path, the sync manager asks PostgREST to ignore a duplicate ID rather than overwrite it. If that produces no returned row, it reads the ID back. A readable row is confirmation that the create landed, and the device adopts it. If that confirmation request fails, the operation stays retryable. An unreadable collision is not accepted as success.

This is not an exactly-once delivery system. It is a retry path that can recover evidence of an insert without replacing an existing row's contents.

Updating the version you actually edited

Sharing a vehicle introduced a different problem. Two people can edit a record based on the same server version, while one or both are offline.

An update filtered only by ID gives the last sync permission to erase the earlier writer's changes. Both requests can succeed. Neither person learns that their edit lost.

Queued updates therefore carry baseUpdatedAt: the server timestamp the device knew before the local edit. The essential write condition looks like this, simplified from lib/sync-manager.ts:

const write = client
  .from(tableName)
  .update(operation.data)
  .eq("id", operation.data.id);

if (operation.baseUpdatedAt) {
  write.eq("updated_at", operation.baseUpdatedAt);
}

const result = await write.select(returningColumns);

The update can land only while the row still has the version it was edited against. The Supabase update API supports returning the affected rows through .select(), which makes an empty match observable to the client.

Capturing the base is easy to get subtly wrong. After the first local edit, the device's row may contain a locally generated timestamp. A second edit must not replace the queued operation's base with that value. The server has never issued it. Coalescing keeps the original server base and merges the payload changes.

A newly created row is another exception. Until the server confirms the create, its local timestamp cannot serve as a server precondition. The queue distinguishes "a create is still recorded" from "a create is still cancellable" for exactly this reason.

An empty update result also needs interpretation. It might mean another writer changed the row. It might mean the row was deleted. On a shared vehicle, it might mean the user's access was revoked or their role changed.

The conflict branch checks whether a readable row exists with a newer server timestamp. Only that evidence produces the stale-write message. The operation goes through the permanent refusal path, the user is told, and the device reconciles with the server. Retrying the rejected payload without the original precondition would recreate the overwrite this check was introduced to prevent.

The current policy is straightforward: the server copy wins a confirmed conflict. There is no field-level merge or conflict editor. An operation from an older build that has no base also cannot receive this protection retroactively. Those are useful limits to state when describing what the system guarantees.

Pull with a server watermark

The pull side needs the same care about timestamps. A phone can be two minutes fast. If it saves its own current time as the delta watermark, it asks the next pull to skip changes through a point in the server's future.

lib/delta-watermark.ts advances the watermark from the timestamps in returned rows instead. An empty response retains the previous watermark. The selected server timestamp is kept in its original string form for the next exclusive comparison, rather than being reformatted through the phone's date serializer.

The phone's clock is useful for retry delays. It cannot certify which server changes the phone has observed.

Retry budgets and regression tests

Failures also have to preserve the difference between "no" and "not now". The queue records total failed attempts separately from failures that count toward its retry limit. Transport failures and known temporary server failures drive capped exponential backoff without spending the permanent failure allowance. A brief service interruption should not remove a locally created vehicle and all the records queued against it.

The regression tests exercise these decisions directly: cancelling an unsent create, keeping a delete after an in-flight create, withholding work from a second flush, recovering a lost insert response, preserving an update's original base, and distinguishing a conflict from missing access. The server and storage are mocked in these tests. They verify the client's decisions, not the deployed policies or a real mobile network.

None of these fixes changed the fuel form. They changed the evidence the sync layer requires before it calls a write cancelled, confirmed, or permanently refused.

If you are building an offline queue, the create-then-delete test is worth adding early. Hold the insert response open, delete the local row, and inspect what remains queued. It exposes an assumption that an ordinary airplane-mode test never reaches.

I build OdoKeep for people who want to keep their vehicle history without making connectivity a requirement for every entry. These are some of the less visible decisions behind that promise. The app is available on the App Store.