Somewhere in the backlog of most Indian mobile products is a ticket that says 'add offline support'. It has been open for a while. It gets moved to the next quarter every quarter, and everyone privately knows it is going to be painful, without quite being able to say why.
Here is why. Offline support is not a feature you add to an app. It is a statement about where the truth lives. In a conventional app, truth lives on the server and the phone is a viewer with a cache. In an offline-first app, truth lives on the phone and the server is a synchronisation partner. Those are different applications that happen to look the same in a screenshot.
The failure mode
The conventional app degrades in a specific and demoralising way. A user in a field with no signal opens the app and sees a spinner. They tap a button and get a red toast. They fill in a form, hit save, the request times out, and the form clears. They lose the work. They do not blame the network — the network is invisible to them. They blame the app, and quite reasonably they stop opening it.
When we built KisanGyan we could not afford that. A farmer standing in the middle of their land, looking at a diseased leaf, is precisely the moment the product exists for — and it is also the moment they are least likely to have a usable connection. If the app only worked on wifi, it only worked at home, where the leaf was not.
The shape of a local-first app
The structural change is simple to describe and invasive to retrofit. The app writes to a local database. Only the local database. The UI reads from the local database and never from a network response. A separate synchronisation layer moves data between local and remote, and it is allowed to fail without the user ever knowing.
UI -> local SQLite -> outbox queue -> sync worker -> API
^ |
+---------------- pull & merge --------------+Three consequences follow immediately, and they are what make this hard to bolt on later.
- 01Every screen becomes a query against local state rather than an async fetch. That changes essentially every widget in the app.
- 02Every write becomes an append to an outbox, processed later. That changes every form, every action and every success state.
- 03Identifiers must be generated on the device, because a record has to exist and be referenceable before the server has ever heard of it.
That third one is the quiet killer. If your primary keys are server-assigned auto-increment integers, you cannot create a record offline without inventing a temporary identity and then rewriting every reference to it once the server responds. Teams discover this in week three of the retrofit and it is usually where the project stalls. Using client-generated UUIDs from the very first migration costs nothing on day one and saves the entire exercise later.
The outbox
The outbox is a local table of pending operations. Each row records what happened, when, and how many times we have tried to send it. A background worker drains it whenever connectivity returns.
- Every operation carries a client-generated idempotency key, so a retry after an ambiguous timeout cannot create a duplicate.
- Failures back off exponentially rather than hammering a recovering network.
- Operations that fail permanently — a validation error, a deleted parent record — move to a dead-letter state and surface in the UI rather than retrying forever in silence.
- The queue is ordered per entity, so an update never overtakes the create it depends on.
Conflicts, and how to mostly avoid them
The literature on conflict resolution is deep, and most products do not need any of it. The reason is that in the overwhelming majority of apps, a given record has exactly one writer: the user who owns it. A farmer's own diary entry, a driver's own delivery proof, a technician's own inspection. Single-writer data does not conflict.
So the practical strategy is to work out which of your entities are genuinely multi-writer, and handle only those carefully. For everything else, last-write-wins with a server timestamp is correct and boring. For the genuine multi-writer cases you have three honest options: merge field by field where fields are independent, use a CRDT where the data structure supports it, or surface the conflict to the user. Surfacing to the user is unfashionable and is frequently the right answer, because the user knows things your merge function does not.
What you get back
The obvious benefit is that the app works without a network. The less obvious benefits are the ones that change how the product feels.
- Every interaction is instant, because nothing waits on a round trip. Local-first apps feel fast in a way that no amount of loading-state polish achieves.
- There are no loading spinners on navigation, so the entire category of skeleton screens disappears from your design system.
- Server load drops substantially, because reads are served locally and writes are batched.
- Your app survives your own outages. A backend deploy that goes wrong becomes an inconvenience rather than a total loss of service.
When not to do this
Offline-first is not free. You are running a distributed system with a replica on every user's phone, and you will spend real engineering time on sync, migrations and debugging state that exists only on one device in one village. It is the wrong choice when your data is inherently shared and real-time — a live auction, a collaborative editor, a trading screen. It is the wrong choice when the data is too large to live on a device, or too sensitive to sit in local storage.
But for field applications, consumer apps outside metros, logistics, agriculture, health workers, sales teams — anything where the user is mobile in the literal sense — it is the difference between a product people rely on and a product people uninstall.
Decide where truth lives before you write your first migration. Everything else is a consequence of that one decision.