Why offline-first matters
You open an app on your phone. Your signal drops in a tunnel. You tap a button. Nothing happens. You tap again. And again. Then the tunnel ends, and you have three duplicate actions, two errors, and a UI that has no idea what state it is in. This is the default experience for apps that are <strong>online-first</strong>: every interaction requires a live connection to a server. When the connection is gone, the app breaks. An <strong>offline-first</strong> app works the other way around. The local device is the source of truth. User actions are applied immediately to local storage. Network sync happens in the background, whenever a connection is available. From the user's perspective, the app always responds instantly - even on a plane, in a basement, or in a tunnel. This guide walks through the complete offline-sync engine built for <strong>Lemitly</strong> - a real production app that runs in the browser, on iPhone (iOS), and on Android. All code examples are taken directly from the codebase. By the end, you will understand:
- How to store data locally in the browser and on mobile devices
- How to track which data is "dirty" (changed locally but not yet saved to the server)
- How to apply changes immediately without waiting for a network response (optimistic updates)
- How to queue actions when offline and replay them when connectivity returns
- How to merge server data back without overwriting unsaved local changes
- How to detect network status on both web browsers and native mobile apps
---
What we are building
Before diving into code, here is a high-level picture of the system:
┌───────────────────────────────────────────────────────┐
│ USER ACTION │
│ (add account, delete tracker, update cooldown...) │
└───────────────────────────┬───────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ OPTIMISTIC UPDATE │
│ Apply change to IndexedDB immediately │
│ Mark row as dirty = true │
│ Update React state from IndexedDB → UI updates NOW │
└───────────────────────────┬───────────────────────────┘
│
┌───────────┴───────────┐
│ │
ONLINE? OFFLINE?
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────────┐
│ Call API directly │ │ Add to Sync Queue │
│ Write confirmed data │ │ (pending status) │
│ Mark dirty = false │ │ Return optimistic data │
└───────────────────────┘ └───────────────────────────┘
│
│ (connectivity returns)
▼
┌───────────────────────────┐
│ Flush Sync Queue │
│ Replay each item in order│
│ Handle conflicts/retries │
└───────────────────────────┘The system is split across six files in <code>lib/offline/</code>:
| File | Responsibility |
|---|---|
| <code>db.ts</code> | IndexedDB schema, CRUD helpers for accounts, trackers, and sync queue |
| <code>types.ts</code> | TypeScript types for metadata, sync queue items, and offline state |
| <code>optimistic.ts</code> | Functions that compute the expected UI state before the server responds |
| <code>syncQueue.ts</code> | Enqueue mutations, flush the queue, replay items against the API |
| <code>reconcile.ts</code> | Merge server snapshots into local storage without overwriting dirty data |
| <code>networkPlatform.ts</code> | Detect and subscribe to connectivity changes on browser and native |
Let us build each piece from the ground up. ---
Part 1: Local storage with IndexedDB
Why not localStorage?
Every browser (and every Capacitor WebView on iOS/Android) has <code>localStorage</code>. It is simple: you store a string, you read it back. But it has a major limitation - it is <strong>synchronous</strong>. Reading a large dataset blocks the JavaScript thread, which freezes the UI. It also has no concept of indexes or queries. <strong>IndexedDB</strong> is the browser's real database. It is:
- Asynchronous (does not block the UI)
- Structured (stores JavaScript objects, not just strings)
- Queryable (supports indexes, key ranges)
- Available on all modern browsers and in Capacitor WebViews on iOS and Android
The trade-off: IndexedDB's native API is verbose and callback-based. So the first thing we do is wrap it in Promises.
Opening the database
// lib/offline/db.ts
const DB_NAME = 'lemitly-offline';
const DB_VERSION = 1;
let dbPromise: Promise<IDBDatabase> | null = null;
export function openOfflineDb(): Promise<IDBDatabase> {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
dbPromise = null; // allow retry on next call
reject(request.error ?? new Error(`Failed to open ${DB_NAME}`));
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create the accounts store (keyed by id)
if (!db.objectStoreNames.contains('accounts')) {
const accounts = db.createObjectStore('accounts', { keyPath: 'id' });
accounts.createIndex('email', 'email', { unique: false });
accounts.createIndex('tombstoned', '_meta.tombstoned', { unique: false });
}
// Create the trackers store
if (!db.objectStoreNames.contains('trackers')) {
const trackers = db.createObjectStore('trackers', { keyPath: 'id' });
trackers.createIndex('accountId', 'accountId', { unique: false });
trackers.createIndex('tombstoned', '_meta.tombstoned', { unique: false });
}
// Create the sync queue
if (!db.objectStoreNames.contains('sync_queue')) {
const queue = db.createObjectStore('sync_queue', { keyPath: 'id' });
queue.createIndex('status', 'status', { unique: false });
queue.createIndex('entityId', 'entityId', { unique: false });
queue.createIndex('createdAt', 'createdAt', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
});
}
return dbPromise;
}<strong>What is happening here:</strong>
- <code>indexedDB.open(DB_NAME, DB_VERSION)</code> opens the database. If it does not exist, it creates it.
- <code>onupgradeneeded</code> runs whenever <code>DB_VERSION</code> increases (or on first open). This is where you define your schema - your "object stores" (equivalent to tables) and "indexes" (equivalent to SQL indexes for fast lookups).
- The result is cached in <code>dbPromise</code> so the database is only opened once per page load.
- <code>keyPath: 'id'</code> means each record's <code>id</code> field is its primary key.
Making requests readable
IndexedDB uses callbacks. We wrap them in Promises so we can use <code>async/await</code>:
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
});
}
function transactionDone(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error('Transaction failed'));
tx.onabort = () => reject(tx.error ?? new Error('Transaction aborted'));
});
}With these helpers, reading a record becomes:
async function getAccount(id: string): Promise<StoredAccount | undefined> {
const db = await openOfflineDb();
const tx = db.transaction('accounts', 'readonly');
const store = tx.objectStore('accounts');
const result = await requestToPromise(store.get(id));
await transactionDone(tx);
return result as StoredAccount | undefined;
}And writing a batch of records:
async function putAccounts(accounts: StoredAccount[]): Promise<void> {
const db = await openOfflineDb();
const tx = db.transaction('accounts', 'readwrite');
const store = tx.objectStore('accounts');
for (const account of accounts) {
store.put(account); // no await here - all puts go into one transaction
}
await transactionDone(tx); // wait for the whole transaction to commit
}Batching all puts into a single transaction is critical for performance. Each <code>store.put()</code> within one transaction is committed in one disk write. ---
Part 2: Tracking local changes with metadata
The <code>_meta</code> field
Every record stored in IndexedDB carries a <code>_meta</code> field - a small object that tracks the sync state of that row:
// lib/offline/types.ts
export interface LocalEntityMeta {
/** True when this record has been soft-deleted, waiting for server confirmation. */
tombstoned: boolean;
/** True when local changes have not been synced to the server yet. */
dirty: boolean;
/** Timestamp of the last local write (milliseconds since epoch). */
clientUpdatedAt: number;
/** The server's `updated_at` value from the last successful sync. */
serverUpdatedAt: string | null;
}This metadata is the control panel for the entire sync engine. The rules are simple:
- <code>dirty: false</code> → the server and the local copy agree. Safe to overwrite with server data.
- <code>dirty: true</code> → there are local changes the server has not confirmed yet. <strong>Do not overwrite.</strong>
- <code>tombstoned: true</code> → the user deleted this record, but it has not been confirmed deleted on the server yet. Show it as "pending delete" in the UI, or hide it entirely.
Every stored entity wraps its domain data with this metadata:
export interface StoredAccount extends Omit<GmailAccount, 'trackers'> {
_meta: LocalEntityMeta;
}
export interface StoredTracker extends ServiceTracker {
accountId: string; // denormalized for efficient lookup
_meta: LocalEntityMeta;
}A helper creates a "clean" default meta object:
export function createDefaultMeta(overrides: Partial<LocalEntityMeta> = {}): LocalEntityMeta {
return {
tombstoned: false,
dirty: false,
clientUpdatedAt: Date.now(),
serverUpdatedAt: null,
...overrides,
};
}Tombstoning: soft deletes for offline safety
Why not just delete a record when the user clicks "Delete"? Because if you hard-delete the record immediately, and then you lose connectivity before the server confirms the deletion, you have no way to know - when you come back online - that this deletion needs to be replayed. The queue item exists, but the local record it references is gone. Instead, we use a <strong>tombstone</strong>: a flag that says "this record should be deleted, but we are waiting for the server to confirm it."
export async function tombstoneAccount(id: string): Promise<StoredAccount | undefined> {
const existing = await getAccount(id);
if (!existing) return undefined;
const updated: StoredAccount = {
...existing,
_meta: {
...existing._meta,
tombstoned: true, // mark for deletion
dirty: true, // there is a pending change
clientUpdatedAt: Date.now(),
},
};
await putAccount(updated);
return updated;
}The UI checks the <code>tombstoned</code> flag and hides (or grays out) accounts and trackers that are pending deletion. When the deletion is confirmed by the server, the record is hard-deleted with <code>purgeAccountLocally</code>. ---
Part 3: Optimistic updates
The problem with waiting
In a traditional app, when you click "Add Account," you see a loading spinner while the app waits for the server to respond. If the server is slow (or offline), the user stares at a spinner for seconds - or forever. Optimistic updates flip this around: <strong>assume success</strong>. Apply the change locally first, show the result to the user immediately, then sync with the server in the background. If the server rejects it, roll back. For most user actions, "optimistic" is the right assumption. The vast majority of writes succeed.
Building an optimistic account
When the user wants to add a new Gmail account, we need to show that account in the UI before the server has assigned it an ID. So we generate a temporary "local ID":
// lib/offline/optimistic.ts
export function createLocalId(prefix: string): string {
return `local-${prefix}-${crypto.randomUUID()}`;
// e.g. "local-account-3f1a8c7b-..."
}Then we construct the full account object exactly as it would look if the server had accepted it:
export function buildOptimisticAccount(
input: NewGmailInput,
clientId: string
): GmailAccount {
const now = new Date().toISOString();
// Build optimistic trackers for each provider the user selected
const trackers: ServiceTracker[] = input.providerSlugs.map((slug) =>
buildOptimisticTracker(slug, input.email, createLocalId('tracker'))
);
return {
id: clientId, // temporary - will be replaced by server ID after sync
email: input.email,
nickname: null,
createdAt: now,
updatedAt: now,
trackers,
};
}For tracker state mutations (like starting a cooldown timer), we compute the expected state locally based on business logic - how many cooldown days the provider uses, what the new status should be:
export function applyTrackerMutation(
tracker: ServiceTracker,
action: 'startCooldown' | 'editCooldown' | 'restart' | 'clear',
lastLimitHitAt?: string
): ServiceTracker {
switch (action) {
case 'startCooldown':
case 'editCooldown': {
const hitAt = lastLimitHitAt ?? new Date().toISOString();
const restoreAt = addDays(new Date(hitAt), tracker.cooldownDays).toISOString();
const remainingSeconds = Math.max(
0,
Math.floor((new Date(restoreAt).getTime() - Date.now()) / 1000)
);
return {
...tracker,
lastLimitHitAt: hitAt,
restoreAt,
status: 'cooling',
remainingSeconds,
progressPercentage: 0,
};
}
case 'clear':
return {
...tracker,
lastLimitHitAt: null,
restoreAt: null,
status: 'ready',
remainingSeconds: 0,
progressPercentage: 0,
};
// ...
}
}The user sees the countdown timer start ticking immediately. The server will confirm (or correct) this state when the network request completes. ---
Part 4: The full mutation flow
Now let us see how all the pieces connect in a real mutation. Here is the complete flow for <code>addAccount</code>:
USER CLICKS "Add Account"
│
▼
1. Generate local ID: clientId = "local-account-abc123"
2. Build optimistic: optimisticAccount = { id: clientId, email: "...", ... }
3. Write to IndexedDB: putAccount({ ...optimisticAccount, _meta: { dirty: true } })
4. Update React state: reloadFromLocal() → setAccounts(...)
↓ UI shows the new account IMMEDIATELY
│
▼
5. Check connectivity: getNetworkConnected()
│
┌────┴─────┐
ONLINE OFFLINE
│ │
▼ ▼
6a. Call API: 6b. enqueue({
create() entityType: 'account',
│ operation: 'create',
▼ payload: { email, providers }
Server gives })
real ID │
│ └─► Return optimistic account to caller
▼
Write confirmed data to IndexedDB
(dirty: false, serverUpdatedAt: created.updatedAt)
Purge local-id entries if server assigned different ID
Reload from IndexedDB → UI updates with real server dataHere is the actual code from <code>hooks/useAccounts.ts</code>:
const addAccount = useCallback(
async (input: NewGmailInput): Promise<GmailAccount> => {
const clientId = createLocalId('account');
const optimistic = buildOptimisticAccount(input, clientId);
// Step 1-4: Write optimistic state, update UI
await putAccount(
toStoredAccount(optimistic, { dirty: true, clientUpdatedAt: Date.now() })
);
for (const tracker of optimistic.trackers) {
await putTracker(
toStoredTracker(tracker, clientId, { dirty: true, clientUpdatedAt: Date.now() })
);
}
await reloadFromLocal(); // UI updates here
// Step 5-6: Try server, or enqueue
const online = await getNetworkConnected();
if (online) {
try {
const created = await accountsApi.create({
email: input.email,
provider_slugs: input.providerSlugs,
});
// Replace optimistic data with server-confirmed data
await putAccount(
toStoredAccount(created, {
dirty: false,
clientUpdatedAt: Date.now(),
serverUpdatedAt: created.updatedAt,
})
);
// Clean up: if server assigned a different ID, purge the local-id entry
if (created.id !== clientId) {
await purgeAccountLocally(clientId);
}
await reloadFromLocal();
return created;
} catch (err) {
if (err instanceof ApiError && err.code === 'network') {
// Lost connectivity mid-request - fall through to enqueue
await enqueue({ entityType: 'account', entityId: clientId,
operation: 'create', payload: { email: input.email,
provider_slugs: input.providerSlugs }, clientTimestamp: Date.now() });
} else {
throw err; // real error - surface to UI
}
}
} else {
await enqueue({ entityType: 'account', entityId: clientId,
operation: 'create', payload: { email: input.email,
provider_slugs: input.providerSlugs }, clientTimestamp: Date.now() });
}
return optimistic; // return local version while sync is pending
},
[reloadFromLocal]
);Notice the pattern is the same for every mutation: <strong>optimistic write → try API → enqueue on failure/offline</strong>. The UI never waits. ---
Part 5: The sync queue
What the sync queue stores
When a mutation is enqueued, it creates a record in the <code>sync_queue</code> IndexedDB store:
// lib/offline/types.ts
export interface SyncQueueItem {
id: string; // UUID
entityType: 'account' | 'tracker';
entityId: string; // the ID of the account or tracker being mutated
operation: 'create' | 'update' | 'delete';
payload: Record<string, unknown>; // the data to send to the server
clientTimestamp: number; // when the user made the change (ms)
createdAt: number; // when it was enqueued - used to preserve order
status: 'pending' | 'syncing' | 'synced' | 'failed' | 'conflict';
retryCount: number;
lastAttemptAt?: number;
errorMessage?: string;
}The <code>status</code> field is the queue item's lifecycle:
pending ──► syncing ──► (removed on success)
│
├──► failed (will retry, up to MAX_SYNC_RETRIES = 3)
│
└──► conflict (permanent failure - needs manual resolution)Flushing the queue
The <code>flush</code> function processes all pending items in order, oldest first:
// lib/offline/syncQueue.ts
export const MAX_SYNC_RETRIES = 3;
let flushInProgress: Promise<FlushResult> | null = null;
export async function flush(): Promise<FlushResult> {
// If already running, return the same promise (no concurrent flushes)
if (flushInProgress) return flushInProgress;
flushInProgress = doFlush().finally(() => {
flushInProgress = null;
});
return flushInProgress;
}
async function doFlush(): Promise<FlushResult> {
const result = { processed: 0, succeeded: 0, failed: 0, skipped: 0, conflicts: 0 };
// Get all pending or previously-failed items, in enqueue order
const items = await getSyncQueue(['pending', 'failed']);
for (const item of items) {
result.processed++;
// Mark as "in progress" so the UI can show a syncing indicator
await updateSyncQueueItem(item.id, {
status: 'syncing',
lastAttemptAt: Date.now(),
});
try {
await replayItem(item); // call the actual API
await removeSyncQueueItem(item.id); // success - remove from queue
result.succeeded++;
} catch (err) {
if (err instanceof ApiError && err.code === 'network') {
// We lost connectivity again - stop processing, try again later
await updateSyncQueueItem(item.id, { status: 'pending', lastAttemptAt: Date.now() });
break;
}
const retryCount = item.retryCount + 1;
if (retryCount >= MAX_SYNC_RETRIES || isConflictError(err)) {
// Permanent failure (or a 409/412 conflict) - mark as conflict
await updateSyncQueueItem(item.id, {
status: 'conflict',
retryCount,
errorMessage: describeError(err),
lastAttemptAt: Date.now(),
});
result.conflicts++;
continue; // process remaining items
}
// Transient failure - mark as failed, will be retried next flush
await updateSyncQueueItem(item.id, {
status: 'failed',
retryCount,
errorMessage: describeError(err),
lastAttemptAt: Date.now(),
});
result.failed++;
}
}
return result;
}Replaying individual items
Each item in the queue is "replayed" by calling the appropriate API method:
async function replayAccountItem(item: SyncQueueItem): Promise<void> {
switch (item.operation) {
case 'create': {
const payload = item.payload as AccountCreatePayload;
const created = await accountsApi.create(payload);
// Update IndexedDB with server-confirmed data
await applyAccountSync(created.id, created, item.entityId);
// If the server assigned a different ID than our local-id, remap everything
if (item.entityId !== created.id) {
await remapAccountId(item.entityId, created.id);
}
return;
}
case 'delete': {
try {
await accountsApi.remove(item.entityId);
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
// Already deleted on the server - that is fine, just clean up locally
await purgeAccountLocally(item.entityId);
return;
}
throw err;
}
await purgeAccountLocally(item.entityId);
return;
}
// ... update case
}
}The ID remapping problem
This is one of the trickiest parts of offline-first architectures. When a user creates an account offline, we give it a temporary local ID like <code>local-account-abc123</code>. When the sync runs, the server assigns it a real ID like <code>42</code>. Now we have a problem: the sync queue still has entries that reference <code>local-account-abc123</code>, and so do all the tracker records. The <code>remapAccountId</code> function solves this:
async function remapAccountId(oldId: string, newId: string): Promise<void> {
// 1. Delete the old local-id account, create it with the real server ID
const account = await getAccount(oldId);
if (account) {
await purgeAccountLocally(oldId);
await putAccount(toStoredAccount({ ...account, id: newId }, { ...account._meta,
dirty: false, serverUpdatedAt: account.updatedAt }));
}
// 2. Update all trackers to point at the new account ID
const trackers = await getTrackersByAccount(oldId, true);
for (const tracker of trackers) {
await putTracker({ ...tracker, accountId: newId });
}
// 3. Update any queue items that reference the old local ID
const items = await getAllSyncQueueItems();
for (const queueItem of items) {
if (queueItem.entityType === 'account' && queueItem.entityId === oldId) {
await updateSyncQueueItem(queueItem.id, { entityId: newId });
}
if (queueItem.entityType === 'tracker' &&
typeof queueItem.payload.accountId === 'string' &&
queueItem.payload.accountId === oldId) {
await updateSyncQueueItem(queueItem.id, {
payload: { ...queueItem.payload, accountId: newId }
});
}
}
}This is atomic in the sense that it happens entirely in local IndexedDB - there is no window where the data is partially migrated from the server's perspective. ---
Part 6: Reconciling server data
The initial hydration
When the user first opens the app (or logs in on a new device), the local IndexedDB is empty. We fetch all the user's data from the server and write it to IndexedDB. This is called <strong>hydration</strong>:
// lib/offline/reconcile.ts
export async function hydrateFromServer(serverAccounts: GmailAccount[]): Promise<void> {
for (const account of serverAccounts) {
await persistAccountFromServer(account);
}
}
async function persistAccountFromServer(account: GmailAccount): Promise<void> {
// Write the account with clean meta (dirty: false)
await putAccount(
toStoredAccount(account, {
dirty: false,
tombstoned: false,
clientUpdatedAt: Date.now(),
serverUpdatedAt: account.updatedAt,
})
);
// Write all its trackers
for (const tracker of account.trackers) {
await putTracker(
toStoredTracker(tracker, account.id, {
dirty: false,
tombstoned: false,
clientUpdatedAt: Date.now(),
serverUpdatedAt: account.updatedAt,
})
);
}
// Delete any local trackers that the server no longer knows about
const existingTrackers = await getTrackersByAccount(account.id, true);
const serverTrackerIds = new Set(account.trackers.map((t) => t.id));
for (const tracker of existingTrackers) {
if (!serverTrackerIds.has(tracker.id) && !tracker._meta.dirty) {
await deleteTrackerRecord(tracker.id);
}
}
}Notice the last check: <code>!tracker._meta.dirty</code>. If there is a local tracker that the server does not know about, but it is <strong>dirty</strong> (meaning the user created or modified it offline), we do not delete it. It will be synced on the next flush.
Subsequent reconciliation
On subsequent app opens, we do not blindly overwrite everything - we <strong>reconcile</strong>:
export async function reconcileAccountsFromServer(
serverAccounts: GmailAccount[]
): Promise<void> {
const localAccounts = await getAllAccounts(true); // include tombstoned
const localById = new Map(localAccounts.map((a) => [a.id, a]));
const serverIds = new Set(serverAccounts.map((a) => a.id));
// Update local records from server, but SKIP dirty ones
for (const serverAccount of serverAccounts) {
const local = localById.get(serverAccount.id);
if (local?._meta.dirty) continue; // user has unsaved changes - do not overwrite
await persistAccountFromServer(serverAccount);
}
// Purge local accounts that are gone from the server (and not dirty)
for (const local of localAccounts) {
if (serverIds.has(local.id)) continue;
if (local._meta.dirty) continue; // pending delete/update - do not purge
await purgeAccountLocally(local.id);
}
}This is the core of the "last-write-wins" strategy:
- If the local record is clean (<code>dirty: false</code>), the server wins.
- If the local record is dirty (<code>dirty: true</code>), the local version wins - it will be pushed to the server on the next sync.
The two-phase load
In <code>useAccounts</code>, this is what the <code>refresh</code> function does on every page load:
const refresh = useCallback(async () => {
setLoading(true);
// Phase 1: Serve stale data from IndexedDB immediately (fast)
await reloadFromLocal(); // → setAccounts(localData) → UI shows something INSTANTLY
// Phase 2: Fetch from server in background (slower)
try {
const serverAccounts = await accountsApi.list();
if (!hydratedRef.current) {
await hydrateFromServer(serverAccounts); // first load
hydratedRef.current = true;
} else {
await reconcileAccountsFromServer(serverAccounts); // subsequent loads
}
await reloadFromLocal(); // update UI with reconciled data
} catch (err) {
if (err instanceof ApiError && err.code === 'network') {
// Offline - the local data we showed in phase 1 is good enough
} else {
console.warn('[useAccounts] Background sync error:', err);
}
}
setLoading(false);
}, [reloadFromLocal]);This is called <strong>stale-while-revalidate</strong>: show the cached data immediately, then update it with fresh data in the background. The user never sees a loading blank screen. ---
Part 7: Network detection across platforms
The challenge
Network detection differs between web browsers and native mobile apps:
| Platform | How to detect connectivity |
|---|---|
| Web browser | <code>navigator.onLine</code>, <code>window</code> events <code>online</code>/<code>offline</code> |
| iOS (Capacitor) | Capacitor Network plugin (<code>@capacitor/network</code>) |
| Android (Capacitor) | Capacitor Network plugin (<code>@capacitor/network</code>) |
The Capacitor Network plugin is more reliable on mobile than <code>navigator.onLine</code>, which can return <code>true</code> even when the device has a connection to a WiFi router but no internet access.
A unified platform abstraction
// lib/offline/networkPlatform.ts
function isNativeCapacitor(): boolean {
if (typeof window === 'undefined') return false;
// Capacitor injects window.Capacitor on native builds
return !!(window as any).Capacitor?.isNativePlatform?.();
}
async function loadCapacitorNetwork() {
if (!isNativeCapacitor()) return null;
try {
return await import('@capacitor/network');
} catch {
return null; // not installed - fallback to web events
}
}
/** Check connectivity right now. */
export async function getNetworkConnected(): Promise<boolean> {
const capacitorNetwork = await loadCapacitorNetwork();
if (capacitorNetwork) {
// Native iOS / Android
const status = await capacitorNetwork.Network.getStatus();
return status.connected;
}
// Web browser
return navigator.onLine;
}
/** Subscribe to connectivity changes. Returns an unsubscribe function. */
export async function subscribeNetworkStatus(
listener: (connected: boolean) => void
): Promise<() => void> {
const capacitorNetwork = await loadCapacitorNetwork();
if (capacitorNetwork) {
// Native: use the Capacitor Network plugin event
const handle = await capacitorNetwork.Network.addListener(
'networkStatusChange',
(status) => listener(status.connected)
);
return () => handle.remove();
}
// Web: use window events
const onOnline = () => listener(true);
const onOffline = () => listener(false);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}The key design decision: <strong>dynamic import</strong> of <code>@capacitor/network</code>. This means the Capacitor plugin code is never bundled into the web browser build. The <code>import()</code> only runs when <code>isNativeCapacitor()</code> is <code>true</code>. The web bundle stays lean.
Triggering sync on reconnect
The <code>useNetworkStatus</code> hook ties everything together. It watches for connectivity changes and automatically flushes the sync queue when the device comes back online:
// lib/offline/useNetworkStatus.ts
export function useNetworkStatus(): { isOnline: boolean; refresh: () => Promise<boolean> } {
const [isOnline, setIsOnline] = useState(true);
const wasOfflineRef = useRef(false);
useEffect(() => {
void subscribeNetworkStatus((connected) => {
setIsOnline(connected);
if (connected && wasOfflineRef.current) {
// We just came back online after being offline
wasOfflineRef.current = false;
void flush(); // replay the sync queue
return;
}
if (!connected) {
wasOfflineRef.current = true;
}
});
// Check initial state
void getNetworkConnected().then((connected) => {
setIsOnline(connected);
if (!connected) wasOfflineRef.current = true;
});
}, []);
return { isOnline, refresh: async () => {
const connected = await getNetworkConnected();
setIsOnline(connected);
return connected;
}};
}The <code>wasOfflineRef</code> tracks whether we were previously offline. This prevents triggering a flush on the initial load (when <code>connected</code> is already true). ---
Part 8: Surfacing offline state to the UI
The <code>offline</code> property
The UI needs to know which items are dirty or tombstoned so it can show appropriate indicators (a sync spinner, a "pending delete" badge, etc.). The <code>getOfflineAccountsWithTrackers</code> function reads from IndexedDB and surfaces <code>_meta</code> as an <code>offline</code> property:
// lib/offline/db.ts
export async function getOfflineAccountsWithTrackers(
includeTombstoned = false
): Promise<OfflineGmailAccount[]> {
const [accounts, trackers] = await Promise.all([
getAllAccounts(includeTombstoned),
getAllTrackers(includeTombstoned),
]);
const trackersByAccount = new Map<string, OfflineServiceTracker[]>();
for (const tracker of trackers) {
const { _meta, accountId, ...serviceTracker } = tracker;
const list = trackersByAccount.get(accountId) ?? [];
list.push({
...serviceTracker,
offline: {
tombstoned: _meta.tombstoned,
dirty: _meta.dirty,
},
});
trackersByAccount.set(accountId, list);
}
return accounts.map((account) => {
const { _meta, ...rest } = account;
return {
...rest,
trackers: trackersByAccount.get(account.id) ?? [],
offline: {
tombstoned: _meta.tombstoned,
dirty: _meta.dirty,
},
};
});
}A component can then do:
function AccountCard({ account }: { account: OfflineGmailAccount }) {
const isPendingDelete = account.offline?.tombstoned;
const isPendingSync = account.offline?.dirty;
return (
<div className={isPendingDelete ? 'opacity-50' : ''}>
<h2>{account.email}</h2>
{isPendingSync && <span>⏳ Syncing...</span>}
{isPendingDelete && <span>🗑 Pending delete</span>}
</div>
);
}---
Putting it all together: the full lifecycle
App opens
│
▼
useNetworkStatus() mounts
Checks connectivity (Capacitor or navigator.onLine)
Subscribes to changes
│
▼
useAccounts() refresh() runs
Phase 1: reloadFromLocal() → UI shows cached data instantly
Phase 2: accountsApi.list() → reconcileAccountsFromServer()
│
├── ONLINE: merge server → IndexedDB, reload → UI updates
└── OFFLINE: show cached data, no error
User performs action (e.g. "start cooldown")
│
▼
useTrackers().startCooldown(id, hitAt)
1. applyTrackerMutation() → optimistic tracker object
2. putTracker({ ...optimistic, _meta: { dirty: true } })
3. upsertTracker() → React state update → UI updates NOW
4. getNetworkConnected()
├── ONLINE: trackersApi.startCooldown() → putTracker(dirty: false)
└── OFFLINE: enqueue({ entityType: 'tracker', operation: 'update', ... })
Connectivity returns
│
▼
useNetworkStatus subscription fires (connected = true)
wasOffline was true → flush()
│
▼
flush() → getSyncQueue(['pending', 'failed'])
For each item in enqueue order:
replayItem(item)
├── SUCCESS: removeSyncQueueItem(id), update IndexedDB dirty: false
├── NETWORK: break (try again next reconnect)
├── TRANSIENT: retryCount++, status: 'failed'
└── CONFLICT: status: 'conflict' (permanent, needs attention)---
Anti-patterns to avoid
Anti-pattern 1: Optimistic update without local storage
Updating React state directly without writing to IndexedDB first:
// ❌ Wrong - state is lost on page refresh
setAccounts(prev => [...prev, newAccount]);
await accountsApi.create(...);// ✅ Right - write to IndexedDB first, reload state from there
await putAccount(toStoredAccount(optimistic, { dirty: true }));
await reloadFromLocal(); // setAccounts(indexedDbData)
await accountsApi.create(...);If the user refreshes the page between the optimistic update and the server confirmation, the data is preserved in IndexedDB. React state rehydrates from there.
Anti-pattern 2: Overwriting dirty records on reconcile
// ❌ Wrong - blindly overwrites local changes with server data
for (const serverAccount of serverAccounts) {
await putAccount(toStoredAccount(serverAccount, { dirty: false }));
}// ✅ Right - skip dirty records
for (const serverAccount of serverAccounts) {
const local = localById.get(serverAccount.id);
if (local?._meta.dirty) continue; // user has unsaved changes
await putAccount(toStoredAccount(serverAccount, { dirty: false }));
}Overwriting a dirty record means silently discarding the user's unsaved changes.
Anti-pattern 3: Hard-deleting before server confirmation
// ❌ Wrong - record is gone, but queue item still references it
await deleteAccountRecord(id);
await enqueue({ operation: 'delete', entityId: id, ... });// ✅ Right - tombstone first, hard-delete only after server confirms
await tombstoneAccount(id); // _meta.tombstoned = true, _meta.dirty = true
await enqueue({ operation: 'delete', entityId: id, ... });
// ...later, when flush() confirms deletion:
await purgeAccountLocally(id); // now safe to hard-deleteAnti-pattern 4: Concurrent flushes
Running multiple flushes simultaneously means the same queue item can be sent to the server twice:
// ❌ Wrong - two flushes running in parallel
window.addEventListener('online', () => flush()); // flush 1
setInterval(() => flush(), 30_000); // flush 2 starts 30s later, flush 1 still runningThe <code>flushInProgress</code> guard in <code>syncQueue.ts</code> prevents this:
// ✅ Right - if a flush is already running, return the same promise
export async function flush(): Promise<FlushResult> {
if (flushInProgress) return flushInProgress; // join the running flush
flushInProgress = doFlush().finally(() => { flushInProgress = null; });
return flushInProgress;
}Anti-pattern 5: No conflict handling
Assuming every replay will succeed. In the real world, a 409 Conflict or 412 Precondition Failed means the server has a newer version of the data. The sync engine must recognize this and not loop-retry forever:
function isConflictError(err: unknown): boolean {
return err instanceof ApiError && (err.status === 409 || err.status === 412);
}
// In doFlush():
if (retryCount >= MAX_SYNC_RETRIES || isConflictError(err)) {
await updateSyncQueueItem(item.id, { status: 'conflict', ... });
// Surface this to the user - they need to resolve the conflict manually
}---
Summary: the six-layer offline stack
┌────────────────────────────────────────────────────────────┐
│ Layer 6: UI (React components) │
│ Reads OfflineGmailAccount[] with .offline.dirty/tombstoned│
│ Shows sync indicators, pending-delete states │
└──────────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────┐
│ Layer 5: Hooks (useAccounts, useTrackers, useNetworkStatus)│
│ Orchestrate the full flow: optimistic write → API or queue│
│ Trigger flush on reconnect │
└──────────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────┐
│ Layer 4: Optimistic builders (optimistic.ts) │
│ Compute expected UI state before server responds │
│ createLocalId, buildOptimisticAccount, applyTrackerMutation│
└──────────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────┐
│ Layer 3: Sync queue (syncQueue.ts) │
│ enqueue(), flush(), replayItem() │
│ Handles retries, conflicts, ID remapping │
└──────────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────┐
│ Layer 2: Reconciliation (reconcile.ts) │
│ hydrateFromServer(), reconcileAccountsFromServer() │
│ Merges server data without overwriting dirty local rows │
└──────────────────────────────┬─────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────┐
│ Layer 1: IndexedDB (db.ts + networkPlatform.ts) │
│ Schema: accounts, trackers, sync_queue stores │
│ CRUD helpers, tombstone/purge, platform network detection │
└────────────────────────────────────────────────────────────┘| What you want to do | Where to look |
|---|---|
| Add a new entity type to sync | <code>db.ts</code> (schema), <code>types.ts</code> (types), <code>syncQueue.ts</code> (replay logic) |
| Add a new mutation | <code>optimistic.ts</code> (builder), hook (orchestration) |
| Change conflict resolution | <code>syncQueue.ts</code> (<code>isConflictError</code>, <code>MAX_SYNC_RETRIES</code>) |
| Change how server data merges | <code>reconcile.ts</code> |
| Add a new platform (e.g. Electron) | <code>networkPlatform.ts</code> |
---
Final thought
Offline-first feels complicated because it inverts the traditional mental model. Instead of asking "did the server accept my change?" before updating the UI, you ask "what is the right thing to show the user right now?" and fix any discrepancy in the background. The implementation has six moving parts, but each part has a single, clear responsibility. IndexedDB stores the data. <code>_meta</code> tracks sync state. Optimistic builders compute expected UI. The queue preserves mutations across connectivity gaps. Reconciliation merges server truth without destroying local work. The network platform adapter makes all of this work identically on a MacBook browser, an iPhone, and an Android phone. Once the foundation is in place, adding new entity types or new mutations follows the same pattern every time. The user stops noticing the network. That is the goal.