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?
| Scenario | Impact Without Offline | Impact With Offline |
|---|---|---|
| User loses connection mid-form | Data lost, user frustrated | Data saved locally, syncs later |
| API goes down for 30 minutes | App is unusable | App works normally |
| Slow 3G connection | Every action takes seconds | Instant responses, sync in background |
| Airplane mode | Nothing works | Full 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
| Storage | Max Size | Async | Structured | Best For |
|---|---|---|---|---|
| IndexedDB | 50%+ of disk | Yes | Key-value / Index | Complex offline apps |
| OPFS (Origin Private File System) | 50%+ of disk | Yes | File-based | Large files, SQLite |
| localStorage | 5-10MB | No | Key-value | Small config, tokens |
| Cache API | 50%+ of disk | Yes | Request/response pairs | API response caching |
| SQLite (via OPFS) | 50%+ of disk | Yes | Relational | Complex 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
| Tool | Type | Best For |
|---|---|---|
| Yjs | CRDT library | Collaborative editing, real-time sync |
| RxDB | Reactive database | Offline-first with real-time sync |
| WatermelonDB | React Native DB | Mobile offline-first |
| PouchDB + CouchDB | Document sync | Full sync protocol |
| TanStack Query | Data fetching | Cache-first with background sync |
| SWR | Data fetching | Stale-while-revalidate pattern |
| Workbox | Service worker | API response caching |
| PowerSync | Sync engine | Postgres-to-SQLite sync |
| ElectricSQL | Sync engine | Active-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
| Mistake | Impact | Fix |
|---|---|---|
| No conflict resolution strategy | Data loss or corruption | Choose LWW, CRDT, or manual merge |
| Syncing everything at once | Slow sync, high bandwidth | Sync incrementally (changed items only) |
| Not handling sync failures | Data stuck in queue forever | Retry with backoff, dead letter queue |
| Using localStorage for large data | 5MB limit, blocks main thread | Use IndexedDB or OPFS |
| Assuming always online | App breaks on first disconnection | Design offline-first from the start |
| Not showing sync status | Users don't know if data is saved | Show pending/synced indicators |
Find APIs with the best offline support on APIScout — sync protocols, webhook support, and real-time data capabilities.