Mobile API Best Practices · Offline-First & Sync
Offline-First Architecture
Local database as the source of truth for the UI, with background sync.
In an offline-first app the UI reads and writes a local database (SQLite/Room/Core Data/Realm) and a sync engine reconciles it with the server. Writes are recorded in a local outbox of pending mutations with client-generated IDs (UUIDv7/ULID) and idempotency keys, then replayed when connectivity returns. The UI shows optimistic state with pending/failed markers.
Connectivity strategies
| Criterion | Online-only | Cache-then-network | Offline-first |
|---|---|---|---|
| Works offline | No | Read-only, stale | Read + write |
| Perceived latency | Network RTT | Instant for cached screens | Instant |
| Complexity | Low | Medium | High (sync, conflicts) |
| Server API needs | CRUD | ETags / cache headers | Delta sync, idempotent mutations, conflict rules |
| Best for | Banking transfers, checkout | News, catalogs | Notes, field work, messaging, tasks |
@Entity(tableName = "pending_mutations")
data class PendingMutation(
@PrimaryKey val id: String = UUID.randomUUID().toString(), // also the Idempotency-Key
val entityId: String, // client-generated ID (ULID)
val op: String, // "create_task", "update_task"
val payloadJson: String,
val baseVersion: Long?, // version the edit was based on
val createdAt: Long = System.currentTimeMillis(),
val attempts: Int = 0,
)
async function flushOutbox(db: Db, api: Api) {
for (const m of await db.pendingMutations.orderBy("createdAt").toArray()) {
try {
await api.post(`/v1/mutations/${m.op}`, m.payload, { headers: { "Idempotency-Key": m.id } });
await db.pendingMutations.delete(m.id);
} catch (e) {
if (isRetryable(e)) break; // stop; keep order, retry later with backoff
await markFailed(db, m, e); // 4xx: surface to the user (conflict/validation)
}
}
}
Common pitfalls
- Server-generated IDs that the client needs before it can reference new records offline.
- Retrying non-idempotent mutations (duplicate orders).
- No UI for permanently failed mutations.
Key points
- Local DB drives the UI
- Outbox of idempotent mutations
- Client-generated IDs
Further reading: Android: Build an offline-first app
Practice questions
Why do offline-first apps use client-generated IDs?
Answer
So records created offline can be referenced immediately (e.g. a comment on a new task) and so retries of the create are naturally idempotent.
What is a mutation outbox?
Answer
A local, ordered queue of pending writes, each with an idempotency key, that a sync engine replays to the server when connectivity returns.
Open in the app to mark lessons complete, take notes and track your progress.