In this post
  1. The question that decides
  2. Why Drift, not raw SQLite
  3. Riverpod and the boundary it enforces
  4. The feature that makes the decision non-negotiable
  5. What you lose, without euphemism
  6. What you gain, and it is a lot
  7. When this choice stops holding
  8. Summary

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 serverWithout one
Signup before first useopen and use
Fails when the network dropsworks the same
Data becomes the studio's responsibilitydata stays on the device
Monthly cost per active userzero cost
Syncs across devicesdoes 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.

The feature that makes the decision non-negotiable

One feature in RodaGrana decides the architecture on its own: the accept simulator. The driver receives a ride offer — fare, distance, estimated time — and the app answers whether it is worth taking, using that driver's real cost per kilometre.

Look at the context in which that happens. The person is in the car. The offer sits on screen for a few seconds before moving to another driver. They may be in a basement garage, on a street with poor signal, or out of mobile data at the end of the month.

The answer has to be immediate and correct, in that order of perceived priority and the reverse order of actual importance.

Where the calculation runsWhat happens in a garage with no signal
On a serverwait, timer expires, ride lost
On the deviceit answers and the decision gets made

There is no elegant "offline mode" that rescues a cloud arrangement here. Caching does not help: the calculation depends on the driver's current costs and on the numbers of that specific offer, which has never been seen before. A sync queue does not help either: the answer is needed now, not when the network returns.

When the core feature has a deadline measured in seconds and happens somewhere with no guaranteed connectivity, local-first stops being an architectural preference and becomes a requirement. Everything else in this post follows from that.

And there is a trust effect: a financial app that stalls at the moment of decision gets uninstalled the first time it happens. Not because the person understood the architecture — because they lost a ride.

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. 1. A social feature. Comparing, sharing, ranking — anything involving two

users requires a server.

  1. 2. Multi-device as the primary use case. Not as a convenience: as the normal

way to use it.

  1. 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.

There is a second-order reason for that, and it is not technical: once user data sits on your servers, removing it becomes a decision with legal and support consequences rather than an engineering task. The cheap moment to not hold somebody's financial history is before you ever hold it.

Summary

  • The deciding question: is there a feature that requires two devices to see the

same data? If not, a server is a free dependency.

  • The accept simulator settles it — an answer needed in seconds, in places with no

signal, cannot come from a network call.

  • Drift gives compile-time typed queries and versioned migrations. Both prevent

errors that would only surface on a user's device.

  • Separating database, repository, provider and widget is what makes the

calculation — the actual product — testable on its own.

  • You lose sync, account recovery, benchmarks and an email channel.
  • You gain instant open, no network failure modes, privacy by construction and

zero operating cost.

  • Local to synced is evolution. The reverse is a rewrite nobody performs.

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.