DR / / drodriguez.site
case study / / 02

PocketRoot

Self-hosted personal finance for the UAE

statuslive
stackExpo + React Native, expo-sqlite, TanStack Query + Zustand
users2,000+
revenuepaying subscribers
teamsolo
updated2026
ExpoReact NativeSQLiteOffline-first
01 / / the problem

Expat finances in the UAE do not fit tools built for other markets. End-of-service gratuity, Zakat, and the monthly remittance home are the three numbers that decide the year, and mainstream budgeting apps model none of them. PocketRoot is built around those three, and it is self-hosted — the household's ledger stays on infrastructure the household controls, rather than in someone else's analytics pipeline.

02 / / what i built
Income, expense and transfer tracking across multiple wallets
Budgets, savings goals, vaults and a debt payoff planner
UAE-specific calculators: end-of-service gratuity, Zakat, remittances
AI receipt scanning, plus an agent that answers questions about your ledger
Shared household accounts with settle-up
Subscription tracking and renewal reminders
Scenario planning and forecasting
Accountant-ready export
Offline-first mobile app — every write queues locally and syncs on reconnect
Free tier, with Plus and Premium plans in AED
03 / / how i built it
Expo + React Native
One codebase shipping to both iOS and Android
expo-sqlite
Local cache and a durable write queue, so the app works with no signal
TanStack Query + Zustand
Server cache and local UI state kept in separate, boring layers
expo-secure-store
Session tokens live in the device keychain, not AsyncStorage
04 / / the product
→ open pocketroot.com

Free tier available — no credit card required.

04c / / deep dive

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.

05 / / what's next

Next on the roadmap:

Bank feed imports to reduce manual entry
Multi-currency holdings for households paid in more than one currency
Native widgets for at-a-glance balances