How the Android app saves offline and syncs later without saving twice
When the phone has no signal and you save a task, the app says “Saved offline” and means it: the write reaches the server later, once, even if you kill the app or update it in between. This is how that promise is kept, and the three bugs that taught us which parts matter.
A save is a paused mutation
Screens read the REST API through a query cache, and a write is a mutation against that cache. Offline, the mutation pauses, an optimistic row goes into the cache so the list shows the task at once, and both are persisted to disk. When the network returns, the paused queue resumes and the mutation sends.
The first bug was that the persisted cache restored the row after an app kill but not the means to send it: a dehydrated mutation carries its key and its variables, not its function. The fix is one table that gives every write a stable key of resource and verb, its function, an idempotent flag and the queries it invalidates. Hooks take their key and function from the table, and on startup the same table registers defaults, so a mutation restored from disk finds its function by key.
Keys outlive releases
The persister is busted by a schema constant, not the build number, so a queue written by one version is replayed by the next. That is deliberate: a person who saved three tasks on the train and updated the app at home must not lose them. The cost is a manual step in every release that touches the table: rename a key or change what a function sends for the same variables, and the constant has to be bumped, or an old queue replays against new code.
Never twice
A replay must not create a row twice. Every create carries an Idempotency-Key derived from the mutation's name, the time it was first submitted and its variables. The server keeps its answer to that key, so a second send of the same create is answered from the record rather than inserted again. The second bug lived here: a create that died mid-flight on the first send after reconnect used to be reported as failed rather than re-sent, because the key was described as a follow-up. It ships now, and the queue relies on it.
Which failures retry
- A 4xx is final. The server understood the request and refused it; sending it again changes nothing. The app tells you: “Not on your plan” when a plan does not cover the change, and “A change saved offline could not be sent” otherwise.
- 502, 503 and 504 mean the request never ran. They are always retried.
- Any other failure is retried only for an idempotent entry, three times with backoff. A non-idempotent write that failed in an unknown way is not guessed at.
- A queued delete that answers 404 is treated as done. The row is gone; that was the point.
The three bugs
- A write killed during its first send after reconnect was lost, because an in-flight mutation was no longer persisted. Since 2.1.5 a replay-safe write stays on disk while it is in flight.
- Wi-Fi with no internet counted as online, so the queue resumed into a void and failed. Since 2.1.7, offline includes a network that is connected but cannot reach the internet, and the Offline pill shows for it.
- The queue resumed all at once with no ordering, so a tick and a later edit of one row, or an edit and a later delete, could land in either order. Since 2.1.17, queued writes of one row are sent oldest first. Since 2.1.14, an edit or delete of a row that is not saved yet is refused before it is queued, with “This project isn't saved yet. Try again once it is.”
The horizon
The persisted cache, queue included, is kept for 30 days. Open the app within that and everything waiting is sent; leave it longer and the queue is discarded with the rest of the cache. The app says so when it happens. Signing out or deleting your account erases everything stored on the phone, unsent changes included, which is why both ask first and count what would be lost.