Skip to content
Mobile Byte Sensei

Offline-first is a data model, not a cache

Most "offline support" is a cache with optimistic writes, and it fails the first time two people edit the same record on a train. What actually works is treating the local database as the source of truth — including the part nobody enjoys, which is deciding who wins a conflict and how a human can audit it.

Mobile team11 min read

On this page (4)

Almost every app that claims offline support has the same architecture: a network layer, a cache in front of it, and optimistic updates that assume the write will eventually succeed. It demos beautifully. It survives a lift. It does not survive a field officer working eight hours out of coverage and then walking into a village with one bar of GPRS.

The failure is not that the cache is badly written. It is that a cache is an optimisation over a network that is assumed to exist, and the moment the network stops being an occasional inconvenience and becomes the normal condition, the assumption inverts.

The inversion

The fix is small to describe and large to implement: the local database becomes the source of truth, and the network becomes an eventual detail. Every write completes locally, immediately, and is durable before the user lifts their finger. Sync is a background reconciliation between two authoritative stores rather than a delivery mechanism for the real one.

Queueing that survives the process

An in-memory queue is not a queue. On Android the process will be killed, and on both platforms the user will force-quit. The work has to be described in a durable store and picked up by something the operating system will resume.

kotlin
@Entity
data class PendingSync(
  @PrimaryKey val id: UUID,
  val recordId: UUID,
  val operation: Operation,   // CREATE | UPDATE | DELETE
  val payload: String,        // serialised, not a lambda
  val attempts: Int = 0,
  val lastError: String? = null,
  val createdAt: Instant,
)
The unit of work is a row, not a closure.

Serialising the payload rather than capturing a closure is the whole trick. A row survives a process death; a lambda does not. It also means the queue is inspectable — you can ask a device what it is waiting to send, which turns an unreproducible support ticket into a database query.

Conflicts are a product decision

This is where most offline projects quietly fail, because the team treats conflict resolution as an engineering problem with a correct answer. It is not. Last-writer-wins, first-writer-wins, field-level merge and manual resolution are all defensible, and which one is right depends entirely on what the records mean.

  • Last-writer-wins is fine for a status field that only ever moves forward.
  • It is actively dangerous for a free-text observation, where it silently destroys someone's work.
  • Field-level merge is usually right and usually more expensive than the team budgeted for.
  • Manual resolution needs a screen, a queue and someone whose job it is — that is a product feature, not a technical detail.

On the land-records project we shipped last-writer-wins on the server with one non-negotiable addition: every superseded version is retained and visible to the district office, which can restore it. That converts an irreversible data loss into a reversible mistake, and it cost about two days.

What it cost

Roughly 30% more engineering time than the cache-shaped version would have taken, nearly all of it in sync and conflict handling rather than in UI. Record loss went from about 12% to under 0.4%, and the district office stopped re-keying entirely.

If your users are always online, do not do any of this. A cache is the right answer and this article is a waste of your afternoon. If they are not, the cache will fail — and it will fail in the field, in front of the people you were trying to help, rather than in your test suite.


Written by Mobile team. Filed under Mobile.

Further reading

All articles

Working on a problem like this one? Request a proposal.