In this post
RodaGrana answers a simple question for rideshare drivers: how much is left from this trip after the car's real cost?
The answer is arithmetic over the user's own data. Nothing in it needs to leave the device.
Even so, the industry reflex would be to put a server in the middle: signup, sync, a back end. This post explains why we did not, and what changes concretely.
The question that decides
Before choosing a stack, one question:
Is there any feature that requires two different devices to see the same data?
For RodaGrana: no. The driver logs their own expenses, drives their own kilometres and sees their own result. No feed, no leaderboard, no sharing.
When the answer is no, a server is not infrastructure — it is a dependency added for free, with costs in latency, availability, privacy and a monthly cloud bill.
| With a server | Without one |
|---|---|
| Signup before first use | open and use |
| Fails when the network drops | works the same |
| Data becomes the studio's responsibility | data stays on the device |
| Monthly cost per active user | zero cost |
| Syncs across devices | does not sync |
The last row is the price. Every other row is the gain.
Drivers work in shopping-centre basements, in garages, on roads with no coverage. A financial-tracking app that needs the network to add two numbers fails exactly where the user is.
Why Drift, not raw SQLite
Compile-time typed queries. Without them, a query is a string. A wrong column name shows up at runtime, on a user's device, months later — and in the worst case it does not show up at all: it returns empty and the app displays zero as if it were a result.
With Drift the schema is declared in Dart, and a query against a nonexistent column does not compile.
Versioned migrations. Every app with a local database eventually changes its schema. Without migration versioning that becomes an if nobody understands six months later — and users two versions behind break.
schema v1 -> v2 : add "cost_per_km" column
schema v2 -> v3 : split "maintenance" into provisioned and actual
That file is what makes shipping an update safe.
Riverpod and the boundary it enforces
State lives in Riverpod, and the main reason is not reactivity — it is layer separation:
database (Drift) -> repository -> provider -> widget
The widget does not know SQLite exists. The repository does not know screens exist. The provider is where business rules live, testable without a widget and without a real database.
The practical gain shows when you want to test the calculation that matters: cost per kilometre, trip profit, daily close. Those functions need no device, no database and no screen to be verified — and they are exactly the ones that cannot be wrong, because an error there means the user makes a bad financial decision.
In an app whose product is a calculation, testing the calculation is worth more than any other test. And it is only cheap if the calculation is not tangled with the UI.
What you lose, without euphemism
No sync. New phone, no history — unless you use export or system backup.
No account recovery. There is no account. Lost device without a backup means lost data.
No aggregate view. With no server there is no way to know what the average user spends per kilometre, so no benchmark between drivers — which would be a legitimate feature.
No email channel. With no signup, feature announcements can only travel through the store.
The first two are mitigable through export. The last two are the real cost, and they are product costs, not engineering ones.
What you gain, and it is a lot
Open and use. No signup screen, no email verification, no waiting. The signup screen is where most apps lose people, and it is curious that it is treated as inevitable.
Always works. No network error state, no spinner, no "try again". The entire class of connectivity failures simply does not exist.
Privacy by construction. There is no server holding drivers' financial data. That is not a privacy-policy promise — it is the fact that the data never leaves the device. Users usually cannot tell those two apart; here they do not have to trust.
Zero operating cost. An app with no back end has no monthly bill. For a small studio that changes which projects are viable.
When this choice stops holding
Three triggers, worth writing down before you need them:
- 1. A social feature. Comparing, sharing, ranking — anything involving two
users requires a server.
- 2. Multi-device as the primary use case. Not as a convenience: as the normal
way to use it.
- 3. Data that must outlive the device. When losing the phone stops being
inconvenient and becomes unacceptable.
None applies today. And the order is favourable: with the local database as the source of truth, adding sync later is a layer on top. Removing the server later would be a rewrite.
Frequently asked questions
Why Drift instead of raw SQLite?
Because Drift gives you compile-time typed queries and versioned migrations — two things you end up writing by hand anyway, and writing worse. SQLite is still underneath; Drift is the layer that avoids SQL in strings and column errors discovered in production.
How do you back up without a server?
File export from the device, plus the operating system backup. It is less convenient than cloud sync and it is a conscious trade: with no account there is nothing to leak and nothing to lose to a service outage.
What if you need cross-device sync later?
Then the local database becomes the source of truth and sync is a layer on top, with a change queue and a conflict policy. Going from local to synced is evolution; going from server to local is a rewrite. The order matters.
Doesn't this limit monetisation?
It limits some paths and opens others. With no account there is no email for retention; on the other hand operating cost is effectively zero and the app works in the first second with no signup — which cuts the drop-off at open, where most apps lose people.