PatternsMobile & PWA
Offline First Architecture
Build apps that work seamlessly offline with proper data sync
Offline First means your app works without a network connection by default. Network connectivity becomes an enhancement, not a requirement. Used by apps like Google Docs, Trello, and Notion.
Core Principles
1. Local data is primary
2. Network is optional
3. Sync when connected
4. Conflict resolution
5. Clear state communicationArchitecture Layers
┌─────────────────────┐
│ UI Components │
├─────────────────────┤
│ State Management │
├─────────────────────┤
│ Local Database │ ← Primary source
├─────────────────────┤
│ Sync Engine │ ← Background sync
├─────────────────────┤
│ Remote API │ ← When available
└─────────────────────┘Local Database (IndexedDB)
// db/schema.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface AppDB extends DBSchema {
posts: {
key: string;
value: {
id: string;
title: string;
content: string;
createdAt: number;
updatedAt: number;
syncStatus: 'synced' | 'pending' | 'conflict';
version: number;
};
indexes: {
'by-sync-status': 'syncStatus';
'by-updated': 'updatedAt';
};
};
drafts: {
key: string;
value: {
id: string;
postId?: string;
content: string;
savedAt: number;
};
};
}
let dbInstance: IDBPDatabase<AppDB> | null = null;
export async function getDB(): Promise<IDBPDatabase<AppDB>> {
if (!dbInstance) {
dbInstance = await openDB<AppDB>('app-db', 1, {
upgrade(db) {
// Posts store
const postsStore = db.createObjectStore('posts', { keyPath: 'id' });
postsStore.createIndex('by-sync-status', 'syncStatus');
postsStore.createIndex('by-updated', 'updatedAt');
// Drafts store
db.createObjectStore('drafts', { keyPath: 'id' });
},
});
}
return dbInstance;
}Data Access Layer
// db/posts.ts
import { v4 as uuidv4 } from 'uuid';
import { getDB } from './schema';
export interface Post {
id: string;
title: string;
content: string;
createdAt: number;
updatedAt: number;
syncStatus: 'synced' | 'pending' | 'conflict';
version: number;
}
export async function createPost(data: Omit<Post, 'id' | 'createdAt' | 'updatedAt' | 'version' | 'syncStatus'>) {
const db = await getDB();
const post: Post = {
id: uuidv4(),
...data,
createdAt: Date.now(),
updatedAt: Date.now(),
syncStatus: 'pending',
version: 1,
};
await db.add('posts', post);
// Trigger background sync
await triggerSync();
return post;
}
export async function updatePost(id: string, updates: Partial<Post>) {
const db = await getDB();
const post = await db.get('posts', id);
if (!post) throw new Error('Post not found');
const updated: Post = {
...post,
...updates,
updatedAt: Date.now(),
syncStatus: 'pending',
version: post.version + 1,
};
await db.put('posts', updated);
await triggerSync();
return updated;
}
export async function deletePost(id: string) {
const db = await getDB();
await db.delete('posts', id);
// Mark as deleted on server
await markAsDeleted(id);
await triggerSync();
}
export async function getAllPosts(): Promise<Post[]> {
const db = await getDB();
return db.getAll('posts');
}
export async function getPendingPosts(): Promise<Post[]> {
const db = await getDB();
return db.getAllFromIndex('posts', 'by-sync-status', 'pending');
}Sync Engine
// sync/engine.ts
export class SyncEngine {
private syncing = false;
private queue: string[] = [];
async sync() {
if (this.syncing) {
console.log('Sync already in progress');
return;
}
if (!navigator.onLine) {
console.log('Offline - skipping sync');
return;
}
this.syncing = true;
try {
// 1. Pull changes from server
await this.pullChanges();
// 2. Push local changes to server
await this.pushChanges();
// 3. Resolve conflicts
await this.resolveConflicts();
} catch (error) {
console.error('Sync failed:', error);
} finally {
this.syncing = false;
}
}
private async pullChanges() {
const lastSync = await getLastSyncTime();
const response = await fetch(`/api/posts/changes?since=${lastSync}`);
const { posts, deletions } = await response.json();
const db = await getDB();
// Apply server updates
for (const serverPost of posts) {
const localPost = await db.get('posts', serverPost.id);
if (!localPost) {
// New post from server
await db.put('posts', {
...serverPost,
syncStatus: 'synced',
});
} else if (localPost.version < serverPost.version) {
// Server has newer version
if (localPost.syncStatus === 'pending') {
// Conflict! Mark for resolution
await db.put('posts', {
...localPost,
syncStatus: 'conflict',
});
} else {
// Update to server version
await db.put('posts', {
...serverPost,
syncStatus: 'synced',
});
}
}
}
// Handle deletions
for (const deletedId of deletions) {
await db.delete('posts', deletedId);
}
await setLastSyncTime(Date.now());
}
private async pushChanges() {
const pending = await getPendingPosts();
for (const post of pending) {
try {
const response = await fetch('/api/posts', {
method: post.version === 1 ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
});
if (response.ok) {
const serverPost = await response.json();
const db = await getDB();
await db.put('posts', {
...post,
version: serverPost.version,
syncStatus: 'synced',
});
} else if (response.status === 409) {
// Conflict detected
const db = await getDB();
await db.put('posts', {
...post,
syncStatus: 'conflict',
});
}
} catch (error) {
console.error('Failed to push:', post.id, error);
}
}
}
private async resolveConflicts() {
const db = await getDB();
const conflicts = await db.getAllFromIndex('posts', 'by-sync-status', 'conflict');
for (const localPost of conflicts) {
// Fetch server version
const response = await fetch(`/api/posts/${localPost.id}`);
const serverPost = await response.json();
// Strategy: Last Write Wins (can be customized)
const resolved = localPost.updatedAt > serverPost.updatedAt
? localPost
: serverPost;
// Update with resolved version
await db.put('posts', {
...resolved,
syncStatus: 'synced',
});
// Push if local wins
if (localPost.updatedAt > serverPost.updatedAt) {
await fetch('/api/posts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(resolved),
});
}
}
}
}
// Singleton instance
export const syncEngine = new SyncEngine();
// Trigger sync
export async function triggerSync() {
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-data');
} else {
// Fallback: sync immediately
await syncEngine.sync();
}
}React Integration
// hooks/usePosts.ts
import { useState, useEffect } from 'react';
import { getAllPosts, createPost, updatePost, deletePost, type Post } from '@/db/posts';
export function usePosts() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadPosts();
// Listen for sync events
navigator.serviceWorker?.addEventListener('message', (event) => {
if (event.data.type === 'SYNC_COMPLETE') {
loadPosts();
}
});
}, []);
async function loadPosts() {
const data = await getAllPosts();
setPosts(data);
setLoading(false);
}
const add = async (post: Omit<Post, 'id' | 'createdAt' | 'updatedAt' | 'version' | 'syncStatus'>) => {
const newPost = await createPost(post);
setPosts(prev => [...prev, newPost]);
return newPost;
};
const update = async (id: string, updates: Partial<Post>) => {
const updated = await updatePost(id, updates);
setPosts(prev => prev.map(p => p.id === id ? updated : p));
return updated;
};
const remove = async (id: string) => {
await deletePost(id);
setPosts(prev => prev.filter(p => p.id !== id));
};
return {
posts,
loading,
add,
update,
remove,
refresh: loadPosts,
};
}Offline Indicator
// components/SyncStatus.tsx
import { useState, useEffect } from 'react';
import { getPendingPosts } from '@/db/posts';
export function SyncStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [pendingCount, setPendingCount] = useState(0);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Check pending items
const checkPending = async () => {
const pending = await getPendingPosts();
setPendingCount(pending.length);
};
checkPending();
const interval = setInterval(checkPending, 5000);
// Listen for sync events
navigator.serviceWorker?.addEventListener('message', (event) => {
if (event.data.type === 'SYNC_START') {
setSyncing(true);
} else if (event.data.type === 'SYNC_COMPLETE') {
setSyncing(false);
checkPending();
}
});
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
clearInterval(interval);
};
}, []);
if (!isOnline) {
return (
<div className="sync-status offline">
<span>📡 Offline</span>
{pendingCount > 0 && (
<span>{pendingCount} changes pending</span>
)}
</div>
);
}
if (syncing) {
return (
<div className="sync-status syncing">
<span>🔄 Syncing...</span>
</div>
);
}
if (pendingCount > 0) {
return (
<div className="sync-status pending">
<span>⏳ {pendingCount} changes syncing</span>
</div>
);
}
return (
<div className="sync-status synced">
<span>✓ All changes saved</span>
</div>
);
}Conflict Resolution UI
// components/ConflictResolver.tsx
import { useState, useEffect } from 'react';
import { getDB } from '@/db/schema';
export function ConflictResolver() {
const [conflicts, setConflicts] = useState<any[]>([]);
useEffect(() => {
loadConflicts();
}, []);
async function loadConflicts() {
const db = await getDB();
const conflicted = await db.getAllFromIndex('posts', 'by-sync-status', 'conflict');
setConflicts(conflicted);
}
const resolveConflict = async (postId: string, resolution: 'local' | 'server') => {
// Implement resolution logic
// ...
await loadConflicts();
};
if (conflicts.length === 0) return null;
return (
<div className="conflict-resolver">
<h3>⚠️ {conflicts.length} Conflicts Need Resolution</h3>
{conflicts.map(conflict => (
<div key={conflict.id} className="conflict">
<h4>{conflict.title}</h4>
<div className="conflict-versions">
<div className="version local">
<h5>Your Version</h5>
<pre>{conflict.content}</pre>
<button onClick={() => resolveConflict(conflict.id, 'local')}>
Keep Mine
</button>
</div>
<div className="version server">
<h5>Server Version</h5>
<pre>{/* Server version */}</pre>
<button onClick={() => resolveConflict(conflict.id, 'server')}>
Keep Server
</button>
</div>
</div>
</div>
))}
</div>
);
}Service Worker Sync
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(performSync());
}
});
async function performSync() {
// Notify start
const clients = await self.clients.matchAll();
clients.forEach(client => {
client.postMessage({ type: 'SYNC_START' });
});
try {
// Perform sync logic
await syncData();
// Notify complete
clients.forEach(client => {
client.postMessage({ type: 'SYNC_COMPLETE' });
});
} catch (error) {
clients.forEach(client => {
client.postMessage({ type: 'SYNC_ERROR', error: error.message });
});
}
}Best Practices
- Local First: Always read/write to local DB first
- Optimistic UI: Update UI immediately
- Background Sync: Sync when possible
- Conflict Strategy: Define clear resolution rules
- Clear Status: Show sync state to users
- Versioning: Track versions for conflict detection
- Error Handling: Graceful degradation
- Storage Limits: Monitor quota usage
- Periodic Cleanup: Remove old data
- Test Offline: Verify all features work
Common Pitfalls
❌ Network-first: Fails when offline
✅ Local-first: always works
❌ No conflict handling: Data loss
✅ Detect and resolve conflicts
❌ Hidden state: User doesn't know status
✅ Clear sync indicators
❌ No versioning: Can't detect conflicts
✅ Track versions on all changes
❌ Sync on every change: Performance hit
✅ Debounce sync, batch changes
Offline First creates resilient apps that work anywhere—prioritize local data and sync intelligently!