There is a version of “works offline” that means the app shows a cached screen and a spinner until the connection comes back. It is the version most products ship, and it is not the same thing at all.
Real offline-first means the local database is the source of truth. A write completes locally and returns immediately. Nothing in the critical path waits on a server. Sync is a background reconciliation process that is allowed to fail, retry, and fall behind without the user ever finding out.
That is one sentence and about three weeks of consequences.
What it costs you
Identity has to be generated on the device. You cannot ask a server for the next invoice number when the server is unreachable. So IDs become client-generated, and you inherit every problem that comes with that: collisions, ordering, and the fact that “the next invoice number” is now a business rule you have to implement rather than a column default you get for free.
Conflicts become a product decision, not a database one. Two devices edit the same customer while both are offline. Last-write-wins is easy and occasionally destroys real data. Field-level merging is correct and complicated. You have to pick, per entity, and you have to be able to explain the choice to a shopkeeper in one sentence.
Migrations run on machines you cannot reach. A server migration is one event you control. A client migration is a distribution: some devices are four versions behind, some have been offline for a month, and one of them will open the app for the first time since March on the day you ship a schema change. Every migration has to be forward-only, idempotent, and survivable in any order.
Testing gets harder in an interesting way. The bugs are not in the happy path; they are in the interleaving. What happens when the app is killed mid-sync? When the device clock is wrong? When the same record syncs twice? These are not edge cases in Indian retail — they are Tuesday.
What it buys you
The app works. Not “degrades gracefully” — works, completely, with the network unplugged.
That sounds like a small distinction until you watch someone use it. A shop counter with three people waiting has no tolerance for a spinner. Power flickers. The connection at a market building is a rumour. The single most common piece of feedback we get is not about a feature; it is that the software does not stop.
It also removes a whole category of support conversation. Nobody has to work out whether the problem is your software, their router, or their operator. The answer to “it isn’t working” is never “check your internet”, which means it is always something you can actually fix.
When not to do it
Offline-first is the wrong default for plenty of software. If your product is inherently collaborative and real-time, if every action needs server-side authorisation, or if the data is too large to sit on a device, you are buying complexity for nothing. A dashboard that reads a warehouse does not need a local write path.
The test we use: if the user is standing in front of a customer, build offline-first. If they are sitting at a desk with a fibre connection and a coffee, don’t. Most software that claims to need offline support is in the second category and has confused resilience with architecture.
The part nobody mentions
Offline-first makes your app smaller and faster almost as a side effect. Once the local database is authoritative, most of your screens are local queries rather than network calls, and the loading states, retry logic and skeleton components that consume a surprising fraction of a normal codebase simply do not exist.
Our billing app installs in around 15 MB. The market leader in the same category needs roughly 250 MB. Not all of that gap is architectural — but a meaningful part of it is what you stop needing once you stop pretending the network is there.