Front-end Engineering Lab
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 communication

Architecture 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

  1. Local First: Always read/write to local DB first
  2. Optimistic UI: Update UI immediately
  3. Background Sync: Sync when possible
  4. Conflict Strategy: Define clear resolution rules
  5. Clear Status: Show sync state to users
  6. Versioning: Track versions for conflict detection
  7. Error Handling: Graceful degradation
  8. Storage Limits: Monitor quota usage
  9. Periodic Cleanup: Remove old data
  10. 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!

On this page