Step 1: The local database is the source of truth
Think of a notebook you carry everywhere, and a filing cabinet at the office:
- You always write in the notebook first, whether or not you’re at the office. Writing never waits.
- You always read from the notebook: it’s with you, so it’s instant.
- When you get to the office, you copy new pages into the cabinet, and copy back anything colleagues added there.
In the app, the notebook is a local database (Room on Android, SwiftData or SQLite on iOS, IndexedDB or SQLite in a web or React Native app), and the cabinet is the server. The UI only talks to the local database. A separate sync process moves changes between the database and the server when the network allows.
Offline-first inverts the usual data flow: the UI observes a local store that is the source of truth for display; writes are applied locally and recorded in an outbox of pending operations; a sync engine pushes the outbox (idempotently) and pulls remote changes since a server-issued cursor, applying them to the store. The hard parts are distributed-systems problems on unreliable links: idempotency under retries, ordering, deletes (tombstones), conflicts between devices and users, partial sync, and doing it all within the OS’s background execution limits.
Offline-first architecture
Tap a box to see what it does.
UI
Observes the local database (a Room Flow, a SwiftData @Query) and renders whatever is there. Never waits for the network to show data or accept a write.
Repository
The only way to change data. A write updates the local database and adds an operation to the outbox, in one local transaction, then asks the sync engine to run.
Local database
All data the app shows, plus each row's sync state. Survives restarts and works offline.
Outbox
Ordered list of operations not yet confirmed by the server (create note abc-123, update, delete). Each has a client-generated id, so sending it twice is harmless.
Sync engine
Runs when there is a network (WorkManager with a network constraint, iOS background tasks, app foreground). Pushes the outbox, then pulls changes since the last cursor and writes them to the local database.
Sync API
Accepts idempotent writes (PUT /notes/{id} with a client id) and returns changes since a cursor, including deletions (tombstones).
Server database
The shared truth between devices. Assigns a version to every change, which is what cursors and conflict checks rely on.
@Model
final class Note {
@Attribute(.unique) var id: UUID // created on the device
var text: String
var updatedAt: Date
var isDeleted = false // deletions are synced too
var pendingSync = true
init(id: UUID = UUID(), text: String) {
self.id = id
self.text = text
self.updatedAt = .now
}
}
@MainActor
final class NotesRepository {
private let context: ModelContext
private let sync: SyncEngine
init(context: ModelContext, sync: SyncEngine) {
self.context = context
self.sync = sync
}
func save(text: String) throws {
context.insert(Note(text: text))
try context.save() // works offline: the note is already saved
sync.requestSync() // runs now if online, or when the network returns
}
}
// The UI reads only the local store:
// @Query(filter: #Predicate<Note> { !$0.isDeleted }, sort: \Note.updatedAt, order: .reverse) var notes: [Note]@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String, // a UUID created on the device
val text: String,
val updatedAt: Long,
val isDeleted: Boolean = false, // deletions are synced too
val pendingSync: Boolean = true,
)
class NotesRepository(
private val dao: NoteDao,
private val workManager: WorkManager,
) {
fun notes(): Flow<List<NoteEntity>> = dao.observeVisible() // the UI reads only this
suspend fun save(id: String = UUID.randomUUID().toString(), text: String) {
dao.upsert(NoteEntity(id = id, text = text, updatedAt = System.currentTimeMillis()))
requestSync() // works offline: the note is already saved
}
private fun requestSync() {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, request)
}
}// A web or React Native app: IndexedDB through Dexie, the same idea as Room or SwiftData
interface Note {
id: string; // a UUID created on the device
text: string;
updatedAt: number;
isDeleted: boolean; // deletions are synced too
pendingSync: boolean;
}
const db = new Dexie("notes") as Dexie & { notes: EntityTable<Note, "id"> };
db.version(1).stores({ notes: "id, updatedAt" });
export const notes = () =>
liveQuery(() => db.notes.filter((n) => !n.isDeleted).reverse().sortBy("updatedAt")); // the UI reads only this
export async function save(text: string, id: string = crypto.randomUUID()) {
await db.notes.put({ id, text, updatedAt: Date.now(), isDeleted: false, pendingSync: true });
requestSync(); // works offline: the note is already saved
}
function requestSync() {
if (navigator.onLine) void syncOnce(); // and again on the "online" event and on app start
}Three decisions make this work:
- The id is created on the phone (a UUID), so a note has its identity before the server ever sees it, and the same note sent twice is still one note.
- Saving never touches the network. The note is in the local database immediately, so the UI shows it and it survives the app being killed.
- Sync runs when it can: WorkManager waits for a network connection and retries with backoff; on iOS the sync engine runs on launch, on network changes and in background tasks; a web app syncs on start, on the browser’s
onlineevent and with Background Sync where it’s supported.
Running on real phones
- Background limits: Android runs sync through WorkManager (constraints, backoff, Doze-aware); iOS gives
BGAppRefreshTaskshort, opportunistic windows andBGProcessingTasklonger ones when charging. Silent push notifications can trigger a sync but are throttled. Sync on foreground is the one moment you can count on. - Schema migrations on the device must handle old local data and unsynced outbox entries from an older app version.
- Security: the local database holds user data at rest; use the platform’s file protection or an encrypted store for sensitive data, and wipe it on sign-out.
- Observability: log sync durations, failures and outbox size; an outbox that keeps growing on some devices is a bug report you’d otherwise never get.
- Source of truth for UI: the local database (Room
Flow, SwiftData@Query, IndexedDBliveQuery); the network refreshes it in the background. - Writes: one local transaction updates the row and adds an outbox entry; client-generated UUIDs.
- Platform: WorkManager (network constraint, backoff); iOS
BGAppRefreshTask/BGProcessingTask; sync on foreground, the one moment you can count on; migrate old outboxes; encrypt sensitive local data and wipe it on sign-out.
Check this part
In an offline-first app, where does the UI read its data from?
Why create ids on the device (UUIDs) instead of on the server?
Why must the local change and its outbox entry be written in one local transaction?
Which moment can a sync engine count on, across Android and iOS background limits?