Building Offline-First Apps with API Sync 2026
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
})
);
Choosing the Right Sync Strategy
The three sync strategies above cover a spectrum from simplest to most powerful, and the right choice depends on your app's use case and tolerance for complexity.
Queue-based sync (Strategy 1) is the right default for most apps. It's easy to reason about, easy to debug, and sufficient for the vast majority of use cases where only one device edits data at a time — task managers, note apps, settings, user-generated content. The critical implementation detail is persistence: the queue must survive page reloads and app restarts, which means storing it in IndexedDB rather than an in-memory array. An in-memory queue loses every pending mutation if the user closes the tab before sync completes.
Last-write-wins (Strategy 2) is simple but dangerous if misapplied. It works well when edits to the same record from multiple devices are rare and data loss is acceptable (e.g., presence data, read receipts, last-seen timestamps). It breaks badly for content that users expect to accumulate: if User A adds a to-do item while offline and User B deletes a different item, LWW based on document-level updatedAt will lose one of the changes. Apply LWW at the field level, not the document level, to reduce data loss.
CRDT-based sync (Strategy 3) is the right choice when multiple users or devices need to edit the same document simultaneously without conflicts. Yjs is the most mature CRDT library for JavaScript and has a rich ecosystem of bindings (ProseMirror, Quill, CodeMirror, TipTap). The tradeoff is complexity: CRDTs add significant library size (Yjs is ~25KB gzipped), require a sync server for real-time collaboration (y-websocket or a hosted option like Liveblocks), and make data inspection and debugging harder because the CRDT document is a binary format, not plain JSON.
For apps where the user has multiple devices but no real-time collaboration (personal task manager, notes app), consider a hybrid: queue-based sync for mutations, polling for reads, and a simple vector clock for conflict detection. This avoids CRDT complexity while handling the common case where a user edits on their phone offline and then syncs when they reach Wi-Fi.
Testing Offline-First Apps
Offline-first behavior is notoriously hard to test in automated test suites because most testing environments don't simulate network conditions. A few practical approaches:
Service worker testing: Use Playwright or Cypress for E2E tests that actually test offline behavior. Both support network interception: page.route('**', route => route.abort()) simulates offline mode, and page.route('**/api/**', handler) can simulate specific API responses. Run your full sync cycle with simulated offline/online transitions to catch edge cases in your event listeners.
Unit test the sync engine in isolation: Your SyncQueue and conflict resolution logic should have pure-function cores that can be unit tested without a browser environment. Pass a fake fetchFn argument to your sync engine rather than using the global fetch — this lets you simulate network errors, delays, and specific responses without mocking globals.
Test the conflict resolution path explicitly: The conflict resolution logic is the most likely place to have bugs and the least likely to be hit in normal usage. Write dedicated tests for each conflict scenario: local-wins (local updatedAt > remote), remote-wins (remote updatedAt > local), and simultaneous edits (equal updatedAt). These tests will catch the off-by-one errors and timezone bugs that only surface when two devices sync the same record.
Production Monitoring for Sync
Once an offline-first app is in production, a new category of observability questions emerge: How often is the sync queue backed up? How long are items sitting in the pending state? Are there records that failed to sync repeatedly and ended up in the dead letter queue?
Key metrics to track: Queue depth (items pending sync), oldest pending item age, sync success rate by operation type (create/update/delete), dead letter queue size, and sync latency (time from offline mutation to successful server sync). Surface these as user-visible indicators when possible — a small "syncing" spinner or "2 changes pending" badge tells the user their data isn't lost; no indicator breeds anxiety and support tickets.
Exponential backoff for retries: The queue-based sync implementation above retries immediately on failure. In production, this creates thundering herd problems when the API is degraded: every client retries at the same time, overwhelming a partially-recovered server. Use exponential backoff with jitter: first retry after 1s, then 2s, 4s, 8s, 16s, capped at 5 minutes. The jitter (add ±25% randomness to each delay) prevents synchronized retry storms across thousands of clients.
Handle sync across sign-in transitions: A common edge case: user A creates data while offline, then signs out and signs in as user B (shared device), then the device comes online. The pending queue from user A's session should not sync to user B's account. Scope the sync queue to the authenticated user ID; on sign-out, either flush (sync remaining mutations before clearing) or discard (if the data should stay local) pending items, depending on your security model.
Methodology
IndexedDB availability is universal in modern browsers (98%+ global coverage per caniuse.com), but the raw API is verbose. The Dexie.js library (dexie npm package) provides a significantly cleaner interface for IndexedDB with Promise-based queries and TypeScript support, and is worth adding for any non-trivial IndexedDB usage. OPFS (Origin Private File System) is supported in Chrome 86+, Safari 15.2+, Firefox 111+, and all modern mobile browsers; it is the recommended foundation for SQLite-in-the-browser via the wa-sqlite or @sqlite.org/sqlite-wasm packages. PowerSync (cloud-hosted sync engine) offers a free tier for up to 3 database connections and 1M synced rows; ElectricSQL is open-source and can be self-hosted. Yjs CRDT document conflict resolution is mathematically proven to converge for all its supported data types (Y.Map, Y.Array, Y.Text); this guarantee does not extend to custom application logic built on top of Yjs, which can still have semantic conflicts even when the CRDT layer merges cleanly.
| 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.
Related: Building an AI Agent in 2026, Building an AI-Powered App: Choosing Your API Stack, Building an API Marketplace