Building an Offline-First Sync Engine: How Lemitly Works on Browser, iPhone, and Android

A beginner-friendly, in-depth guide to building an offline-sync engine in TypeScript. Covers IndexedDB, optimistic updates, sync queues, conflict resolution, and cross-platform network detection - with real code from the Lemitly codebase.

Jul 30, 202617 min read

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&#39;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 &quot;dirty&quot; (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>:

FileResponsibility
<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&#39;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&#39;s native API is verbose and callback-based. So the first thing we do is wrap it in Promises.

Opening the database

typescript
// 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 &quot;object stores&quot; (equivalent to tables) and &quot;indexes&quot; (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: &#39;id&#39;</code> means each record&#39;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>:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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 &quot;pending delete&quot; in the UI, or hide it entirely.

Every stored entity wraps its domain data with this metadata:

typescript
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 &quot;clean&quot; default meta object:

typescript
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 &quot;Delete&quot;? 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 &quot;this record should be deleted, but we are waiting for the server to confirm it.&quot;

typescript
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 &quot;Add Account,&quot; 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, &quot;optimistic&quot; 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 &quot;local ID&quot;:

typescript
// 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:

typescript
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:

typescript
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 data

Here is the actual code from <code>hooks/useAccounts.ts</code>:

typescript
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:

typescript
// 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&#39;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:

typescript
// 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 &quot;replayed&quot; by calling the appropriate API method:

typescript
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:

typescript
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&#39;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&#39;s data from the server and write it to IndexedDB. This is called <strong>hydration</strong>:

typescript
// 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>:

typescript
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 &quot;last-write-wins&quot; 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:

typescript
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:

PlatformHow 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

typescript
// 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:

typescript
// 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 &quot;pending delete&quot; badge, etc.). The <code>getOfflineAccountsWithTrackers</code> function reads from IndexedDB and surfaces <code>_meta</code> as an <code>offline</code> property:

typescript
// 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:

tsx
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:

typescript
// ❌ Wrong - state is lost on page refresh
setAccounts(prev => [...prev, newAccount]);
await accountsApi.create(...);
typescript
// ✅ 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

typescript
// ❌ Wrong - blindly overwrites local changes with server data
for (const serverAccount of serverAccounts) {
  await putAccount(toStoredAccount(serverAccount, { dirty: false }));
}
typescript
// ✅ 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&#39;s unsaved changes.

Anti-pattern 3: Hard-deleting before server confirmation

typescript
// ❌ Wrong - record is gone, but queue item still references it
await deleteAccountRecord(id);
await enqueue({ operation: 'delete', entityId: id, ... });
typescript
// ✅ 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-delete

Anti-pattern 4: Concurrent flushes

Running multiple flushes simultaneously means the same queue item can be sent to the server twice:

typescript
// ❌ Wrong - two flushes running in parallel
window.addEventListener('online', () => flush()); // flush 1
setInterval(() => flush(), 30_000);              // flush 2 starts 30s later, flush 1 still running

The <code>flushInProgress</code> guard in <code>syncQueue.ts</code> prevents this:

typescript
// ✅ 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:

typescript
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 doWhere 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 &quot;did the server accept my change?&quot; before updating the UI, you ask &quot;what is the right thing to show the user right now?&quot; 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.