Skip to main content
Engineering 30 June 2026 · 7 min read

Building offline-first mobile apps for India

Notes on connectivity, storage, sync conflicts, and why your best-designed app will fail if a farmer in Sangli can't use it with two bars of 3G.

PI

Parth Infotech mobile team

Parth Infotech

If you’re building a mobile app for anyone outside Tier-1 India, you have a connectivity problem. If you don’t think you do, you will find out on launch day. Here’s what we’ve learned from ten years of shipping apps for farmers, field officers, distributor sales reps, warehouse staff, and government fieldwork teams.

The connectivity mental model

Forget “online” versus “offline.” Real usage looks like a mix:

  • Reliable Wi-Fi at home base. Once a day, sometimes twice.
  • Intermittent 4G/3G on the move. Bars come and go every 30 seconds.
  • Long stretches of nothing. Two hours between towers is common in western Maharashtra. Longer in Vidarbha.
  • Batteries that will die if the radio keeps searching. Rural 4G on an entry-level Android is a battery graveyard.

Design for the second and third states. If the app is unusable when it can’t reach the server, users learn to open it only at home base — at which point they might as well be using a spreadsheet.

Storage: local is the source of truth

Every write from the user goes to a local store first, then syncs. Not the other way around. On Android we default to Room (or WatermelonDB when we need cross-platform), on iOS we use Core Data or GRDB. React Native gets WatermelonDB or a straight SQLite wrapper.

Two rules that came out of getting this wrong the first time:

  1. The local store is the source of truth for the user. The server is the source of truth for the business. Both are correct. Your sync layer’s job is to keep them in agreement, not to arbitrate.
  2. Never block the UI on network. If a save is attempted with no signal, the app should behave exactly as if it succeeded. The user should never see a spinner because the tower is 40 km away.

Sync strategy: pull, push, resolve

We use a three-phase model for most apps:

Pull. On app open, pull new records from the server. Use a sync_cursor per collection, not a full sync. If it’s been three months since the user opened the app, don’t try to download three months of data at once — page it, and let the user work while it streams.

Push. After every local write, queue a push. The queue is durable (survives app restart) and persists on the local store, not in memory. If a push fails, the queue retries with backoff. If it fails permanently (server rejects the write with an actionable error), the queue surfaces it as a user-visible conflict rather than swallowing it silently.

Resolve. Conflicts are inevitable — two field officers editing the same record from two devices with poor connectivity. Our default resolution: last-write-wins with a per-field diff shown to the user next time they open the record. For domains where losing a write is catastrophic (financial data, medical records), we use per-field CRDTs and hand-tuned resolution — but that’s a big commitment and shouldn’t be the default.

Data model tricks

A few things that repeatedly earn their keep:

Client-generated IDs. Every new record gets a UUID at creation time, on the device, before the network sees it. This lets the record be referenced immediately in local relations. The server accepts it as-is if it’s unique; if not, we detect the collision on sync and remap.

Soft delete, always. Never hard-delete anything on a device. The user might undo. The sync might resurrect. Keep a deleted_at timestamp and filter in queries.

Timestamps in UTC, always. Timezone bugs on multi-region deployments will eat weeks of your engineering time. Store UTC, render local.

Cursor-based pagination, not offset. Offset pagination breaks the moment two devices write concurrently. Cursor-based sync gracefully handles insert-in-the-past scenarios that are common when devices come back online after days.

The parts you can’t skip

Three things that are boring but you cannot cut corners on:

A background sync worker. On Android this is WorkManager with a NetworkType.CONNECTED constraint and exponential backoff. On iOS it’s BGProcessingTaskRequest. Battery-saver mode, doze mode, foreground service quirks — all of it needs testing on the actual devices your users own, not just Pixel 8s.

A visible sync status. Farmers and field officers need to know when their last write actually reached the server. We put a small badge in the app bar: pending count, last sync time. Nothing more. That badge has saved us more support calls than any documentation ever did.

A “resync from server” escape hatch. When something goes sideways — usually because a device sat unpowered for three weeks and the sync cursor is now dangerous to trust — the user needs a way to nuke local state and re-sync clean. It’s a Settings toggle called “Reset data (advanced).”

What we’ve stopped recommending

  • Realm for new projects. It served us well from 2016 to about 2021, but MongoDB’s stewardship since then has been uneven. Room/GRDB/WatermelonDB have caught up.
  • Firebase’s offline mode as an “offline-first” story. It’s fine for casual usage, but the offline semantics aren’t strong enough for a serious field-ops app.
  • Rolling our own sync engine unless we have to. For most projects, WatermelonDB or a hand-rolled queue-based sync over REST beats spending three months building a custom differential-sync framework.

The one-line summary

If your app doesn’t work well with two bars of 3G, it doesn’t work well. That’s not a special case. That’s the normal case. Design for it from day one and everything else gets easier.

If you’d like to talk about a specific offline-first architecture problem, we’d be happy to help.

Tags

  • #mobile
  • #offline-first
  • #India
  • #sync
  • #engineering

Have a project in mind?
Let's see if we're the right fit.

A 30-minute call with a senior engineer — free, no sales pitch. We'll tell you honestly whether we can help.