The problem with "offline support"
Most apps that claim to work offline mean they cache reads. You can look at yesterday's numbers on the metro, but the moment you try to record something the app spins and then loses it.
For a personal finance app that's exactly backwards. Reading your balance is a nice-to-have. Logging the coffee you just bought, in a basement car park with no signal, is the entire product. If that write is lost, the ledger is wrong, and a finance app with a wrong ledger is worse than no app.
So PocketRoot treats every write as something that must survive: the app being offline, the app being killed, and the phone being restarted.
Two tables, and why they're separate
The local SQLite database has exactly two concerns, and keeping them apart is the whole design.
- →
cache— the last known server state per view, with the timestamp it was written. Disposable. If it vanishes, the app refetches. - →
sync_queue— writes the user has made that the server has not yet accepted. Sacred. If it vanishes, data is lost.
Collapsing these into one "offline store" is the mistake that makes offline sync hard. Cached reads can be thrown away at any time; pending writes cannot. Different durability requirements, different tables, different rules.
Writes go to the queue first
A new transaction is written to sync_queue with its operation type, a JSON
payload, a creation timestamp, and an attempt counter. That happens
synchronously, on the device, before any network call is attempted — so the
write is durable the instant the user taps save, whether or not there is signal.
The UI updates from local state immediately. There is no spinner, because there is nothing to wait for.
Draining the queue, in order, without losing anything
A NetInfo listener watches connectivity. The app treats itself as online only
when the connection is up and the internet is actually reachable — the
distinction matters, because a captive portal or a hotel wifi that has stopped
forwarding traffic will happily report "connected".
On the transition from offline to online, the queue drains. Three properties matter:
- →Ordered. Operations come out
ORDER BY created_at ASC, so they hit the server in the order the user performed them. A transaction created before an edit is applied before that edit. - →Removed only on success. An operation is deleted from the queue after the server accepts it, never before. A crash mid-drain leaves the operation in the queue, and it retries on next connect.
- →Stops on the first network failure. If an operation fails, the loop increments its attempt counter and breaks rather than continuing.
That last one is the least obvious and the most important. The naive implementation keeps going and tries the rest of the queue. If the server is unreachable, all that does is burn through every pending operation's retry budget while guaranteeing they all fail — and, worse, it can apply later operations while an earlier one is still pending, which silently reorders the user's ledger. Stopping preserves ordering and leaves the queue intact for the next attempt.
Reads: cache first, always
Screens don't fetch and then render. They read from cache synchronously on mount, render immediately with whatever is there, and then revalidate in the background if the device is online. A screen with cached data never shows a loading state; it shows the numbers and quietly updates them.
Each cache entry carries its write timestamp, so the UI can tell the user how stale what they're looking at is — which is the honest thing to do in an app where a stale balance and a current balance look identical.
The full refresh path uses Promise.allSettled rather than Promise.all, so
one failing endpoint degrades one card instead of blanking the dashboard. On a
flaky connection, partial data beats an error screen.
What I'd do differently at ten times the size
The queue currently assumes operations are independent and commutative enough that ordering by creation time is sufficient. That holds for a personal ledger, where conflicts are rare and the same user owns every write. It would not hold for the shared household accounts as they grow: two people editing the same budget from two phones need real conflict resolution — server-side version vectors, or last-writer-wins with an explicit merge UI — rather than ordering alone.
The attempt counter is also recorded but not yet acted on. The obvious next step is exponential backoff keyed on attempts, and a dead-letter state for an operation the server keeps rejecting — because an operation that will never succeed should surface to the user rather than retry forever in silence.
Neither is hidden. Both are the kind of thing worth knowing about your own system before a client finds it.