What you'll learn
- Quick answer
- Why Offline-First Matters, Especially in India
- Two Architectural Postures: Local Buffer vs Local Replica
- The Outbox Pattern: The Buffer Posture, In Practice
- Conflict Resolution Strategies, Compared
- A Real Field-Level Merge, Written and Run
- What Can Go Wrong: Clock Skew and Duplicate Writes
- Choosing an Approach for Your Own App
- FAQ
Quick Answer
Quick Answer: Offline-first means your app reads and writes to a local database first and treats the network as a background job that reconciles later, instead of something every single tap depends on. The actually hard part is not working offline — it is reconciling two devices that both changed the same record while disconnected, which is where strategies like last-write-wins, per-field timestamps, and CRDTs come in, each trading off simplicity against how much data loss they are willing to risk.
Why Offline-First Matters, Especially in India
A typical Indian user’s mobile network experience is not “always connected with occasional drops” — it is routinely patchy: a metro commute through tunnels, a village with one tower and inconsistent 4G, a crowded train where everyone’s phone is fighting for the same cell signal. An app that treats every tap as a network request shows a loading spinner in exactly these moments, which is precisely when the user most wants the app to just work.
Offline-first flips the default: every screen renders from a local database first, every write commits to that local database immediately (so the UI feels instant, with zero network latency in the loop), and a separate sync layer reconciles the local store with the server whenever a connection is actually available. A notes app, a task manager, or a course-progress tracker (marking a lesson complete on a train, with no signal, and having it actually stick) are the classic cases where this is the difference between an app people trust and one they do not.
The tradeoff is real engineering complexity, concentrated almost entirely in one place: what happens when the same piece of data was changed on two different devices while both were offline, and now both are trying to sync. That single question is what the rest of this article is actually about.
It is worth being precise about what offline-first is not, because the term gets stretched to cover things it does not mean. It does not mean the app pretends the server does not exist, or that every feature has to work fully offline forever (a live payment cannot complete without a network, full stop, no matter how the app is architected). It means the default path for ordinary reads and writes does not block on the network, and the app degrades gracefully — clearly communicating what is pending sync versus what is confirmed — rather than either freezing or silently losing data the moment connectivity drops.
Two Architectural Postures: Local Buffer vs Local Replica
There are two meaningfully different ways to structure an offline-first app, and most of the confusion around “offline-first is complicated” comes from not picking one on purpose.
Local store as a buffer. The server remains the single source of truth. The device holds a working copy of the data plus an “outbox” of pending writes that have not reached the server yet. If a conflict happens, the server’s rule wins (or a simple, documented policy decides), and in the worst case a device can always be repaired by throwing away its local copy and re-pulling fresh data from the server. This is the right default for most apps: a course platform, a shopping app, a to-do list synced across a phone and a laptop.
Local store as a replica. Truth is negotiated among peers, and the server is just one more replica in the system rather than an authority above it. This is the posture of real-time collaborative editors and some note-taking apps that promise “edit the same document on two devices and it just merges.” It buys real resilience (there is no single point that must be reachable for the app to make progress) but costs genuine distributed-systems machinery: every replica needs a way to detect that two changes happened concurrently rather than one after the other, and a merge function for every data type that can produce the same result regardless of which replica merges first.
Picking the wrong posture for your app’s actual needs is the most expensive mistake in this space — building full peer-replica machinery for a to-do list that genuinely only needed a buffer-and-outbox is months of avoidable work.
The Outbox Pattern: The Buffer Posture, In Practice
For the buffer posture, the concrete implementation is almost always an “outbox”: a local table of pending operations (“create note X,” “mark lesson Y complete,” “delete item Z”) that is written to immediately when the user takes an action, completely independent of network state. A background worker watches this table and, whenever a connection is available, drains it — sending each pending operation to the server, and only removing it from the outbox once the server confirms it was applied.
Two details make the difference between a clean implementation and a fragile one. First, retries need exponential backoff rather than hammering the server the instant a connection flickers back for one second — a flaky network produces exactly the pattern (brief connectivity, then nothing) that turns a naive immediate-retry loop into a burst of failed requests. Second, every operation sent from the outbox needs an idempotency key, because the same operation can genuinely be sent twice — the request succeeds on the server but the confirmation never makes it back to the device before the connection drops again, so the device, having no way to know the write actually landed, retries it. Without an idempotency key, that retry can create a duplicate record instead of recognizing “oh, this exact operation already happened.”
A practical detail that trips up a lot of first implementations: the outbox table and the “normal” local data table are two different things, and conflating them causes confusing bugs. The data table should always reflect the app’s current best understanding of reality (including optimistic, not-yet-confirmed changes, so the UI feels instant), while the outbox is purely a to-do list of network operations still owed to the server. A completed note should disappear from the outbox the moment the server confirms it, but the note itself should keep existing in the data table regardless — these are two separate lifecycles that are easy to accidentally merge into one if the outbox is implemented as an afterthought rather than designed in from the start.
Conflict Resolution Strategies, Compared
| Strategy | How it works | Pros | Cons |
|---|---|---|---|
| Whole-record last-write-wins | Newer timestamp replaces the entire record | Very simple to implement | Silently discards an unrelated field changed by the other device |
| Per-field last-write-wins | Each field keeps its own timestamp; newer timestamp wins per field | Simple, and both devices’ unrelated edits survive | Still lossy if the SAME field was edited on both devices — one edit is discarded |
| Operational Transform | Transforms each operation against concurrent ones so intent is preserved (used by real-time collaborative text editors) | Can merge genuinely overlapping edits, like two people typing in the same sentence | Complex to implement correctly; usually needs a library, not a weekend project |
| CRDTs (Conflict-free Replicated Data Types) | Data structures mathematically designed so any merge order produces the same result | Strong, provable consistency guarantees across any number of replicas | Real complexity moves into choosing or writing the right CRDT per data type — counters, sets, and text all need different ones |
Per-field last-write-wins is the sweet spot for a large share of real apps: dramatically simpler than Operational Transform or a full CRDT library, while being meaningfully less lossy than whole-record last-write-wins. It is a reasonable default unless your app’s core feature is literally real-time collaborative editing, in which case the extra complexity of OT or CRDTs is the actual point of the product, not overhead to avoid.
One more distinction worth having clear in your head before picking a row from this table: conflict detection and conflict resolution are separate problems, and it is possible to be good at one and bad at the other. Detection means correctly recognizing that two changes happened concurrently rather than one strictly after the other — a vector clock or a per-field version counter is a detection tool. Resolution is what you do once you know there is a conflict — last-write-wins, union, or a real merge function. A system that detects conflicts perfectly but resolves them by always discarding one side is not meaningfully better than one that never bothered to detect them in the first place; the resolution strategy is where the actual user-facing behaviour lives.
A Real Field-Level Merge, Written and Run
To make this concrete rather than abstract, here is a real per-field last-write-wins merge, written in Dart and run with dart run against a scenario: a student edits the same saved note on their phone (offline, on a train) and on their laptop (offline, at home) before either device syncs.
NoteRecord mergeRecords(NoteRecord local, NoteRecord remote) {
final merged = <String, FieldValue>{};
final allKeys = {...local.fields.keys, ...remote.fields.keys};
for (final key in allKeys) {
final a = local.fields[key];
final b = remote.fields[key];
if (a == null) {
merged[key] = b!;
} else if (b == null) {
merged[key] = a;
} else if (a.updatedAtMs != b.updatedAtMs) {
merged[key] = a.updatedAtMs > b.updatedAtMs ? a : b;
} else {
// exact timestamp tie -> deterministic tie-break by device id,
// so merge(A, B) and merge(B, A) always agree
merged[key] = a.deviceId.compareTo(b.deviceId) >= 0 ? a : b;
}
}
return NoteRecord(local.id, merged);
}The phone’s version had a newer body edit timestamp on the laptop’s side and an older one on the phone’s side for that same field, plus an unrelated, untouched title field. Running mergeRecords(phone, laptop) and mergeRecords(laptop, phone) — merging in both possible orders — produced identical results both times: the body and tags fields correctly took the laptop’s newer edits, while title was preserved from whichever side had the newer timestamp for that specific field, even though neither device had actually changed it. No field was silently overwritten by an edit to a different field, which is exactly the failure whole-record last-write-wins would not have caught.
That order-independence — merge(A, B) equals merge(B, A) — is not a nice-to-have, it is the actual requirement for a sync system where you cannot control which device happens to sync first. A merge function that gives different results depending on sync order will produce two devices that each believe they are "correct" and disagree forever.
What Can Go Wrong: Clock Skew and Duplicate Writes
Last-write-wins, at any granularity, has one structural weakness: it depends entirely on comparing timestamps, and a device’s clock is not a trustworthy source of truth. A phone with its clock set wrong (travelers crossing time zones without automatic time sync enabled is a surprisingly common real case) can make an actually-older edit look newer, silently discarding a more recent change on a different device. Two mitigations are common: using a logical clock (a simple incrementing counter per device, sometimes combined into a vector clock, instead of wall-clock time) so ordering does not depend on the device’s system clock being correct, or having the server assign the authoritative timestamp at sync time rather than trusting whatever timestamp the device reports.
The second recurring failure is the duplicate-write problem mentioned under the outbox pattern: a retried operation that actually succeeded the first time but whose confirmation never reached the device. This is solved the same way idempotent API design solves it everywhere else — every outbox operation carries a unique id generated on the device when the operation was first created, and the server checks “have I already applied an operation with this exact id” before applying it again, rather than relying on the client to somehow know not to retry.
A third, subtler failure worth knowing about: partial sync, where some operations in a batch succeed on the server and others fail, and the device has to figure out which is which rather than treating the whole batch as uniformly succeeded or failed. The safe default is to make every individual outbox entry its own request with its own idempotency key and its own success/failure outcome, rather than batching several operations into one request that can only report success or failure as a whole — the latter makes it genuinely ambiguous, after a partial failure, which parts are safe to retry and which would create a duplicate.
Choosing an Approach for Your Own App
A practical decision guide, roughly matching what production apps actually do:
- Simple CRUD app with occasional offline use (a course platform, a shopping list, most student projects): outbox pattern plus per-field last-write-wins. This covers the large majority of real apps without needing a specialized library.
- Append-only activity (marking lessons complete, logging workouts, analytics-style events): model it as an event log rather than a mutable record, and merge by taking the union of both devices’ events rather than overwriting anything — two devices marking the same lesson complete twice is harmless, so there is nothing to actually conflict over.
- Genuinely collaborative editing (two people typing in the same document, a shared whiteboard): this is the case where reaching for an existing CRDT or Operational Transform library is the right call, not over-engineering — correctly implementing either from scratch is a serious distributed-systems project on its own.
The single most useful question to ask before writing any sync code at all is simply: “if two devices both changed this exact field while offline, what should happen?” If you cannot answer that in one sentence for every piece of data your app syncs, that is the design work to do before writing the merge function, not after.
It is also fine, and often correct, to use different strategies for different pieces of data within the same app. A course platform might use an append-only event log for lesson-completion events (safe to union, never lossy), per-field last-write-wins for profile details like a display name or bio (simple, and good enough for data nobody edits simultaneously in practice), and simply disallow offline editing entirely for something like a payment method, where correctness matters more than offline availability. Treating conflict resolution as one app-wide decision, rather than a per-feature one, is itself a common and avoidable source of over-engineering.
Frequently Asked Questions
Is offline-first the same thing as caching?
No, and the difference matters. Caching means the app can still display previously-fetched data when offline, but writes typically fail or queue invisibly, and the app still treats the network as the primary path. Offline-first means writes succeed locally and immediately regardless of connectivity, and syncing is a background concern the user does not have to wait on or even think about.
Do I need a CRDT library for a simple to-do app?
Almost certainly not. A to-do app fits the outbox pattern plus per-field last-write-wins comfortably — reaching for a full CRDT library is usually solving a harder problem than the app actually has, at the cost of real complexity and a steeper learning curve for anyone who later maintains the code.
What happens if two devices create a brand-new record with the same ID while both offline?
This is a real edge case worth designing for explicitly: generate IDs that are extremely unlikely to collide across devices (a UUID generated on-device, rather than a sequential integer assigned by the server) so that two offline-created records never actually fight over the same identity in the first place. If a true collision does occur, the server sync step needs an explicit policy — typically reject the second one and have that device retry with a fresh ID.
How do push notifications fit into an offline-first app?
Push notifications are a separate, server-initiated channel and do not change the offline-first model underneath — a notification can wake the app and trigger a sync, but the app’s local data should already be correct and usable even if that notification never arrives at all, which is exactly the point of being offline-first in the first place.
Does offline-first work well with something like Firestore or Firebase?
Firebase’s Firestore ships built-in offline persistence and a basic last-write-wins conflict model out of the box, which covers a meaningful share of simple apps without any custom sync code. Apps with more specific merge requirements (per-field merging, append-only event logs, or real collaborative editing) typically still build custom logic on top rather than relying on Firestore’s default conflict behaviour for those specific fields.
