Support

Lesson 7 of 11 ·12 min read

Offline Data and Sync

The hard part of apps: two databases, disconnected most of the time, that must eventually agree.

After this lesson you can explain

  • Why offline support is a ladder of five rungs, each costing roughly ten times the one below, and which rung most apps actually need.
  • Why the UI reads only from the local database, and what the outbox pattern adds once writes happen offline.
  • Which five features make an API sync-ready, and why a data model that cannot conflict beats conflict-resolution machinery.

1. Why this is the hard part

On the web you may assume the network exists; when it doesn't, the page fails and the user retries. An app cannot do that. It is expected to open instantly in a tunnel, show everything the user cares about, and never lose what they typed. The moment you honour that expectation, the app becomes a distributed system: a database on the phone and a database behind your API, disconnected most of the time, that must eventually agree.

Everything in this lesson is downstream of that sentence. The good news: the complexity comes in well-understood tiers, and most apps only need the cheap ones.

2. The ladder of offline support

Offline support is not one feature. It is a ladder, and each rung costs roughly ten times the one below it. The most important design decision is picking a rung deliberately instead of drifting up the ladder by accident.

Rung 1

Online-only

Every screen fetches on open; aeroplane mode means spinners and error states. Entirely respectable for a v1, and for apps whose data is worthless when stale (live prices, ride tracking).

Rung 2

Read cache (stale-while-revalidate)

Remember the last response; on open, show it instantly, refresh in the background, and update the screen if anything changed. A few hours of work, and it delivers most of the perceived value of offline: the app opens instantly and always shows something. This is the correct rung for most small apps.

Rung 3

Offline reads

A proper local database holds all the user's data, kept fresh by background sync. The app fully works offline, read-only. Requires the single-source-of-truth architecture (§3) and a delta-sync endpoint (§6).

Rung 4

Offline writes

The user can create and edit offline; changes upload later. The big jump: now you need an outbox queue, optimistic UI, idempotent retries, and a conflict story (§4–5). Do not climb here casually.

Rung 5

Full multi-device / collaborative sync

Several devices editing the same data concurrently (Notion, Figma, Google Docs). CRDTs and operational transforms live here. Research-grade; buy, don't build (§7).

3. The architecture: single source of truth

From rung 2 upward, the pattern that keeps everything sane is:

The UI never renders network responses directly. The UI reads only from the local database, re-rendering reactively when it changes. The network layer's only job is to update the local database.

        WRONG (fetch-and-render)               RIGHT (single source of truth)

   UI <--- render ---- network response      UI <-- reactive query -- local DB
   |                                                                     ^
   +-- spinner while fetching,               network sync layer ---------+
       blank when offline                    (background: pull deltas,
                                              push outbox)

The payoff: the app behaves identically online and offline (same code path, same screens), just with fresher or staler data. Loading spinners mostly disappear; "offline mode" stops being a mode. Every serious on-device store is built around this reactive-query pattern: Room (Android), Core Data / SwiftData (iOS), WatermelonDB (React Native), Drift (Flutter); nearly all of them SQLite underneath.

Storage tierWhat it isUse for
Key-value (AsyncStorage, SharedPreferences)Small string storeSettings, tokens (secure variant), a single cached JSON blob (rung 2)
SQLite via an ORM layerReal relational DB on the deviceRungs 3–4: queryable, indexable local data with reactive queries
Sync-native stores (Firestore offline, Realm/Atlas, PowerSync, Replicache)DB with sync engine built inRungs 4–5 when you'd rather configure sync than write it

4. Offline writes: the outbox pattern

Writes made offline go into a local outbox: a table of pending operations.

-- the outbox, conceptually
id (uuid)  | op      | payload                        | created_at | attempts
a1b2...    | create  | {"list":"wk32","text":"..."}   | 10:41      | 0
c3d4...    | rename  | {"item":"789","text":"..."}    | 10:44      | 0
  1. Optimistic UI: the local write happens immediately and the UI updates from the local DB: the user sees their change now, not after upload.
  2. A background drainer sends queued operations, in order, when connectivity returns.
  3. Idempotency keys: each operation carries its client-generated UUID; the server treats a repeated key as "already done, return the previous result." This solves the classic failure: the network dies after the server processed the request but before the response arrived. Without the key, the retry creates a duplicate.
  4. Retries with exponential backoff, preserving per-item order; an operation that keeps failing for a non-network reason (validation, permissions) must surface to the user, not retry forever.

Recognise the shape: this is the same lesson as the push and login lessons. The reliability machinery (idempotency, ordering, retry semantics) lives in the API contract, not in the app. An app can only be as offline-robust as the API beneath it allows.

5. Conflicts, and how real apps mostly dodge them

A conflict: you edited a note offline; the same note changed server-side meanwhile (another device, another user). The textbook resolution strategies, in ascending order of effort:

StrategyHowTrade-off
Last-write-wins Whoever uploads last overwrites Trivial; silently destroys the other edit. Acceptable only for low-stakes single-user data
Versioned writes Client sends the version its edit was based on; server rejects stale writes with 409 Conflict + current state; client merges or asks the user The workhorse. This is HTTP's own ETag/If-Match machinery: optimistic concurrency, familiar from API land
Field-level merge Track changes per field, not per record; disjoint edits merge cleanly More bookkeeping; kills most "conflicts" that are really edits to different fields
CRDTs / OT Data structures that mathematically guarantee convergence (CRDTs) or transform concurrent edits (OT, Google Docs) Handles simultaneous editing of the same text; complex. Rung 5, buy it

The practical wisdom, though, is that well-designed apps avoid most conflicts structurally instead of resolving them heroically:

  • Ownership: data owned by one user, edited from one device at a time, rarely conflicts. Most personal apps are here by default.
  • Append-only shapes: log entries, messages, orders, events cannot conflict: two devices appending produces two entries, both valid. Modelling edits as appended events ("renamed X at 10:41") rather than mutated records makes sync dramatically easier.
  • Narrow writes: "update this one field" conflicts far less often than "replace this whole record."

Design the data model first; reach for resolution machinery only where genuinely concurrent editing survives the redesign.

6. What sync demands from your API

A "sync-ready API" is a specific, learnable shape with five features:

  1. A delta endpoint: GET /items?since=<cursor> returning only what changed plus a new cursor, instead of the full collection every time. (A cursor issued by the server beats a client-supplied timestamp: clocks skew, and a server cursor can encode ordering exactly.)
  2. Tombstones: deletions must appear in the delta as explicit markers ("deleted": true). A row that simply vanishes from responses can never be noticed by a client that already holds it. This is the classic sync bug: ghosts that live forever on the phone.
  3. Idempotency keys accepted on all writes (§4).
  4. Versioned writes with 409 + current server state on staleness (§5).
  5. Batch endpoints: draining a 50-item outbox should not be 50 round-trips on hotel WiFi; accept an array, return per-item results.
GET /planner/items?since=cursor_8fk2
→ {
    "changes": [
      { "id": "123", "text": "Send curl tool to PyCoder's", "version": 7 },
      { "id": "98",  "deleted": true }              ← the tombstone
    ],
    "next_cursor": "cursor_9am1"
  }

7. Buy vs build

OptionWhat it gives youCost
Build rungs 2–3 yourself Cache + delta sync over your own API; full control, no lock-in Modest, well-trodden; the five API features above
Build rung 4 yourself Outbox + versioned writes over your own API Significant but bounded, if conflicts are dodged structurally (§5)
Sync services (Firestore offline, Realm/Atlas Device Sync, PowerSync, Replicache) Rungs 3–5 largely configured rather than coded Vendor lock-in at the data layer (the hardest layer to migrate later), pricing that scales with usage, and their data model constraints become yours
Rung 5 from scratch - Don't. Teams of specialists work on this full-time

8. Common gotchas

SymptomCause
Deleted items reappear on some devices foreverNo tombstones; clients can't learn about deletions they already hold
Duplicate records after flaky uploadsRetries without idempotency keys
Sync misses items edited during the sync itselfClient-clock timestamps as the cursor; use server-issued cursors
One user's edit silently vanishesLast-write-wins where versioned writes were needed
App shows stale data after a successful writeUI rendered the network response instead of reading the local DB (§3 violated)
An unsyncable item blocks the whole outboxValidation failures retried like network failures; dead-letter and surface them
First sync takes minutes on a new deviceDelta endpoint but no efficient initial-snapshot path; provide both

9. Applying it: the planner app

For a personal planner client (from Building Your First App), the right answer is deliberately low on the ladder:

  • Rung 2 is the target. Cache the planner blob locally, render instantly on open, refresh in the background, pull-to-refresh for manual sync. This is most of the felt benefit for a screen's worth of code.
  • Writes: either require connectivity to edit (honest and simple), or keep a one-slot outbox for the single "update the blob" operation with an idempotency key.
  • Conflicts: one user, mostly one device at a time: structurally dodged. If the phone and the desktop planner ever race, versioned writes on the blob (reject-and-refresh on 409) is one afternoon of work.

The takeaway shape: offline is a ladder, not a feature; climb deliberately. The architecture is "UI reads local, network writes local." The reliability is in the API contract: deltas, tombstones, idempotency, versions, batches. And the cheapest conflict strategy is a data model that cannot conflict.

Check your understanding

  • What does rung 2 (stale-while-revalidate) deliver, and why is it the right target for most small apps?
  • Why must deletions appear as explicit tombstones in a delta endpoint?
  • The network dies after the server processed a write but before the response arrived. What stops the retry creating a duplicate?

Reviewed 4 August 2026.