Skip to main content

Building Offline-First Apps with API Sync

·APIScout Team
offline-firstsyncpwaapi integrationmobile

Building Offline-First Apps with API Sync

Offline-first means your app works without internet, then syncs when connectivity returns. It's not just for mobile — flaky Wi-Fi, airplane mode, subway commutes, and rural areas make offline capability essential. The challenge: keeping local and remote data in sync without conflicts.

Why Offline-First?

ScenarioImpact Without OfflineImpact With Offline
User loses connection mid-formData lost, user frustratedData saved locally, syncs later
API goes down for 30 minutesApp is unusableApp works normally
Slow 3G connectionEvery action takes secondsInstant responses, sync in background
Airplane modeNothing worksFull functionality

The Architecture

┌─────────────────────────────────────┐
│  UI Layer                           │
│  (reads from local store always)    │
└──────────┬──────────────────────────┘
           │
┌──────────▼──────────────────────────┐
│  Local Store                        │
│  (IndexedDB / SQLite / OPFS)        │
│  Source of truth for reads          │
└──────────┬──────────────────────────┘
           │
┌──────────▼──────────────────────────┐
│  Sync Engine                        │
│  (background sync when online)      │
│  Handles conflicts, retries         │
└──────────┬──────────────────────────┘
           │
┌──────────▼──────────────────────────┐
│  Remote API                         │
│  (server, eventually consistent)    │
└─────────────────────────────────────┘

Key principle: UI always reads from local store. Writes go to local store first, then sync to remote.

Storage Options

StorageMax SizeAsyncStructuredBest For
IndexedDB50%+ of diskYesKey-value / IndexComplex offline apps
OPFS (Origin Private File System)50%+ of diskYesFile-basedLarge files, SQLite
localStorage5-10MBNoKey-valueSmall config, tokens
Cache API50%+ of diskYesRequest/response pairsAPI response caching
SQLite (via OPFS)50%+ of diskYesRelationalComplex queries

IndexedDB Basic Pattern

class OfflineStore {
  private db: IDBDatabase | null = null;

  async init() {
    return new Promise<void>((resolve, reject) => {
      const request = indexedDB.open('app-store', 1);

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        // Create stores
        const todoStore = db.createObjectStore('todos', { keyPath: 'id' });
        todoStore.createIndex('syncStatus', 'syncStatus');
        todoStore.createIndex('updatedAt', 'updatedAt');
      };

      request.onsuccess = (event) => {
        this.db = (event.target as IDBOpenDBRequest).result;
        resolve();
      };

      request.onerror = () => reject(request.error);
    });
  }

  async put(store: string, item: any): Promise<void> {
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(store, 'readwrite');
      tx.objectStore(store).put({
        ...item,
        syncStatus: 'pending',
        updatedAt: Date.now(),
      });
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async getAll(store: string): Promise<any[]> {
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(store, 'readonly');
      const request = tx.objectStore(store).getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async getPending(store: string): Promise<any[]> {
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(store, 'readonly');
      const index = tx.objectStore(store).index('syncStatus');
      const request = index.getAll('pending');
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

Sync Strategies

Strategy 1: Queue-Based Sync

Queue mutations offline, replay them when online:

class SyncQueue {
  private queue: Array<{
    id: string;
    action: 'create' | 'update' | 'delete';
    resource: string;
    data: any;
    timestamp: number;
    retries: number;
  }> = [];

  async enqueue(action: string, resource: string, data: any) {
    this.queue.push({
      id: crypto.randomUUID(),
      action: action as any,
      resource,
      data,
      timestamp: Date.now(),
      retries: 0,
    });

    // Try sync immediately if online
    if (navigator.onLine) {
      await this.sync();
    }
  }

  async sync() {
    const pending = [...this.queue];

    for (const item of pending) {
      try {
        await this.processItem(item);
        // Remove from queue on success
        this.queue = this.queue.filter(q => q.id !== item.id);
      } catch (error) {
        item.retries++;
        if (item.retries > 5) {
          // Move to dead letter queue
          console.error('Max retries exceeded:', item);
          this.queue = this.queue.filter(q => q.id !== item.id);
        }
      }
    }
  }

  private async processItem(item: typeof this.queue[0]) {
    const url = `https://api.example.com/v1/${item.resource}`;

    switch (item.action) {
      case 'create':
        await fetch(url, { method: 'POST', body: JSON.stringify(item.data) });
        break;
      case 'update':
        await fetch(`${url}/${item.data.id}`, { method: 'PUT', body: JSON.stringify(item.data) });
        break;
      case 'delete':
        await fetch(`${url}/${item.data.id}`, { method: 'DELETE' });
        break;
    }
  }
}

// Listen for online/offline events
window.addEventListener('online', () => syncQueue.sync());

Strategy 2: Last-Write-Wins

Simplest conflict resolution — most recent change wins:

async function syncWithLastWriteWins(localItems: Item[], remoteItems: Item[]) {
  const merged: Item[] = [];
  const allIds = new Set([
    ...localItems.map(i => i.id),
    ...remoteItems.map(i => i.id),
  ]);

  for (const id of allIds) {
    const local = localItems.find(i => i.id === id);
    const remote = remoteItems.find(i => i.id === id);

    if (!local) {
      merged.push(remote!); // Only exists remotely
    } else if (!remote) {
      merged.push(local); // Only exists locally (new)
    } else if (local.updatedAt > remote.updatedAt) {
      merged.push(local); // Local is newer
    } else {
      merged.push(remote); // Remote is newer
    }
  }

  return merged;
}

Strategy 3: CRDT-Based (Conflict-Free)

For collaborative apps where conflicts must be resolved automatically:

// Using Yjs for CRDT-based sync
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebsocketProvider } from 'y-websocket';

// Create CRDT document
const ydoc = new Y.Doc();

// Persist locally in IndexedDB
const localProvider = new IndexeddbPersistence('my-app', ydoc);

// Sync with server when online
let wsProvider: WebsocketProvider | null = null;

function goOnline() {
  wsProvider = new WebsocketProvider('wss://sync.example.com', 'room', ydoc);
}

function goOffline() {
  wsProvider?.disconnect();
}

// Use shared data types
const todos = ydoc.getArray<Y.Map<any>>('todos');

// Add a todo (works offline, syncs automatically)
const todo = new Y.Map();
todo.set('id', crypto.randomUUID());
todo.set('text', 'Buy groceries');
todo.set('done', false);
todos.push([todo]);

// Changes merge automatically — no conflicts possible

Tools for Offline-First

ToolTypeBest For
YjsCRDT libraryCollaborative editing, real-time sync
RxDBReactive databaseOffline-first with real-time sync
WatermelonDBReact Native DBMobile offline-first
PouchDB + CouchDBDocument syncFull sync protocol
TanStack QueryData fetchingCache-first with background sync
SWRData fetchingStale-while-revalidate pattern
WorkboxService workerAPI response caching
PowerSyncSync enginePostgres-to-SQLite sync
ElectricSQLSync engineActive-active Postgres sync

Service Worker for API Caching

// service-worker.ts
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';

// Cache API responses with stale-while-revalidate
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/products'),
  new StaleWhileRevalidate({
    cacheName: 'api-products',
    plugins: [{
      cacheWillUpdate: async ({ response }) => {
        // Only cache successful responses
        return response?.status === 200 ? response : null;
      },
    }],
  })
);

// Cache-first for static API data
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/config'),
  new CacheFirst({
    cacheName: 'api-config',
    plugins: [{
      expiration: { maxAgeSeconds: 86400 }, // 24 hours
    }],
  })
);

// Network-first for user-specific data
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/user'),
  new NetworkFirst({
    cacheName: 'api-user',
    networkTimeoutSeconds: 3, // Fall back to cache after 3s
  })
);

Common Mistakes

MistakeImpactFix
No conflict resolution strategyData loss or corruptionChoose LWW, CRDT, or manual merge
Syncing everything at onceSlow sync, high bandwidthSync incrementally (changed items only)
Not handling sync failuresData stuck in queue foreverRetry with backoff, dead letter queue
Using localStorage for large data5MB limit, blocks main threadUse IndexedDB or OPFS
Assuming always onlineApp breaks on first disconnectionDesign offline-first from the start
Not showing sync statusUsers don't know if data is savedShow pending/synced indicators

Find APIs with the best offline support on APIScout — sync protocols, webhook support, and real-time data capabilities.

Comments