Most to-do apps know two states: done and not done. F4Goals, the task manager I build and run, treats partial progress as a first-class value: you slide a task to say how far along it is, and subtasks nest as deep as the work does. It also had to work on a train with patchy signal, so sync could not be an afterthought.
It ships to the web and to Google Play from one Expo and React Native codebase. Here is the design that keeps an offline edit from ever being lost.
1. Local first
All state lives on the device (Zustand with persistence), so every interaction is instant and nothing waits on the network. The server is a replica you sync with, not the source of truth for what the screen shows.
2. A durable outbox
Every write is queued, persisted, and flushed to Supabase when the device is online. The rule that matters most: a rejected write is never deleted. It moves to a persisted dead-letter list and is retried when the connection returns. A failed sync becomes a delayed edit, not a lost one.
I learned this the hard way. An earlier version treated "request rejected" as "will never succeed" and dropped the operation, and an insert carrying a column the database did not have yet took the only copy of a task with it. Keeping rejected work fixed that class of bug for good.
3. Ids made on the device
The app generates UUIDs itself instead of waiting for the database. An offline-created task can be a parent or a progress target before the server has ever seen it, and an insert goes out as an upsert on that id, so replaying a request whose response was lost cannot create a duplicate.
4. When the id should be derived, not random
Random ids are not always right. I record one progress snapshot per project per day. With random ids, a stale fetch response that made the app forget a row it had just created led it to mint a second row on the next render. A table ended up with 46 rows for 14 distinct days. The charts hid it, the table kept growing.
The fix was to derive the id from the natural key: a UUID v5 of (user, project, day). The same logical row now always has the same id, so a repeat is an upsert onto itself and two devices converge on one row. I verified my small synchronous SHA-1 and v5 implementation against the RFC 4122 reference value before trusting it.
5. Native where it counts
Two Android home-screen widgets are written in Kotlin and fed from a JSON snapshot the app writes to shared storage. They are wired in through an Expo config plugin so they survive rebuilds. Releases go out through EAS builds, with Sentry watching for errors.
What I would tell you if you are building the same thing
- Keep rejected work. Never drop a write because it failed once.
- Mint ids on the client, and make every write idempotent.
- If a row has a natural key (one per day, one per user and item), derive its id from that key.
F4Goals is young and I do not quote install numbers. If you want this kind of engineering on your own app, I take on React Native builds, rescues and maintenance: React Native app development services. The full write-up is in the F4Goals case study.
Top comments (0)