Project updates
3 min read
Designing offline sync for Basket
A grocery store basement is a bad place to depend on a network. Here is how the offline queue for Basket is taking shape, and where the hard decisions are.
Most list apps assume a connection. Open the app, load the list, tap an item, wait for the server to confirm. That works until you walk into the basement level of a supermarket where the signal disappears between the entrance and the dairy aisle.
Basket is a shared grocery list for a household. The whole point is that the list is correct for everyone, which makes a dropped connection a product problem rather than an inconvenience.
The naive version
The first version of any list app calls the API on every change. Optimistic UI hides the latency: the checkbox flips immediately, and the request goes out in the background. If it fails, you either retry silently or show an error.
That approach fails in a specific, annoying way. If the request never lands and the user has already left the screen, the local state says "bought" and the server says "not bought". The next person to open the list sees a different reality. Multiply that by two people in two aisles and the list becomes useless.
So the question is not "how do we retry a request". It is "what does the device believe, and how do we make that belief converge".
Local first, server authoritative
The model I settled on keeps two ideas separate:
- The device maintains its own copy of the list and applies every change locally, immediately.
- The server is the source of truth for conflict resolution, but it is not required in order to use the app.
Every mutation is appended to a queue with a client-generated identifier. When the connection returns, the queue is replayed in order.
{
"opId": "01J9Z6W4K2",
"listId": "list_household",
"type": "item.claim",
"itemId": "item_milk",
"clientId": "device_android",
"createdAt": "2026-10-24T14:02:11.482Z",
"payload": { "claimedBy": "member_2" }
}The operation id matters for two reasons. It makes the replay idempotent if the server processed the request but the response was lost, and it gives the activity feed something stable to reference.
Replay is the easy half
Replaying a queue in order is straightforward. The difficult part is what happens when two queues contain contradictory operations.
Two members are shopping at the same time. One claims the milk. The other marks the milk as bought while offline. Both operations are valid from the perspective of the device that made them.
The first rule I adopted: the server resolves in arrival order, and the client accepts the server state after replay. That is simple, explainable, and produces a predictable result. It is also occasionally surprising, which is why the app shows a short note when the server overrides a local change.
The second rule: conflicts are resolved per item, not per list. If one person edits the milk and another edits the bread, neither should lose their change.
The third rule is a preference rather than a guarantee: when two operations are equally valid, the more specific one wins. A claim is more specific than a check-off, because it carries intent about who is buying something.
What I am deliberately not doing
I am not building a CRDT-based sync layer. It is the correct answer to a harder question, and it would add a class of bugs I do not want to debug from a supermarket.
I am also not doing peer-to-peer sync between devices. The server stays involved, because somebody has to be the tiebreaker, and a household list does not need a distributed consensus protocol.
The part that is still unresolved
The queue works. The reconciliation rules are written. The remaining question is how the interface should communicate a conflict without becoming noisy. A notification for every override would be terrible. Silence would be worse.
The current direction is an activity entry that is visible but not interrupting, with a subtle indicator on the affected item. That is a design problem disguised as a sync problem, which is most of what building this app has felt like.
If you want to follow the project, the project page is the place I keep the current status.
Written by
Mohannad Bakkar
Technology professional, systems thinker, and hands-on builder working across software, integrations, and technical delivery.
Previous article
What a home lab taught me about DNSRelated articles
All articlesInfrastructure where the internet is optional
Notes from planning monitoring for a rural property with intermittent power and connectivity.
What a home lab taught me about DNS
When the resolver goes down, everything goes down with it. The outage was self-inflicted, and the lesson stuck.
Integrations fail at the edges
The happy path is the easy part. Most integration problems live in timestamps, retries, identity, and assumptions about who owns the data.