--- name: capacitor-offline-first description: Guide to building offline-first Capacitor apps with data synchronization, caching strategies, and conflict resolution. Covers SQLite, service workers, and network detection. Use this skill when users need their app to work without internet. --- # Offline-First Capacitor Apps Build apps that work seamlessly with or without internet connectivity. ## When to Use This Skill - User needs offline support - User asks about data sync - User wants caching - User needs local database - User has connectivity issues ## Offline-First Architecture ``` ┌─────────────────────────────────────────┐ │ UI Layer │ ├─────────────────────────────────────────┤ │ Service Layer │ │ ┌─────────────┐ ┌─────────────────┐ │ │ │ Online Mode │ │ Offline Mode │ │ │ └──────┬──────┘ └────────┬────────┘ │ ├─────────┼──────────────────┼────────────┤ │ │ Sync Manager │ │ │ └────────┬─────────┘ │ ├──────────────────┼──────────────────────┤ │ ┌───────────────┴───────────────────┐ │ │ │ Local Database │ │ │ │ (SQLite / IndexedDB) │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘ ``` ## Network Detection ### Using Capacitor Network Plugin ```bash bun add @capacitor/network bunx cap sync ``` ```typescript import { Network } from '@capacitor/network'; // Check current status const status = await Network.getStatus(); console.log('Connected:', status.connected); console.log('Connection type:', status.connectionType); // Listen for changes Network.addListener('networkStatusChange', (status) => { console.log('Network status changed:', status.connected); if (status.connected) { // Back online - sync data syncManager.syncPendingChanges(); } else { // Offline - show indicator showOfflineIndicator(); } }); ``` ### Network-Aware Service ```typescript import { Network } from '@capacitor/network'; class NetworkAwareService { private isOnline = true; constructor() { this.init(); } private async init() { const status = await Network.getStatus(); this.isOnline = status.connected; Network.addListener('networkStatusChange', (status) => { this.isOnline = status.connected; }); } async fetch(url: string, options?: RequestInit): Promise { if (!this.isOnline) { // Return cached data return this.getCachedData(url); } try { const response = await fetch(url, options); const data = await response.json(); // Cache the response await this.cacheData(url, data); return data; } catch (error) { // Network error - try cache return this.getCachedData(url); } } } ``` ## Local Database with SQLite ### Installation ```bash bun add @capgo/capacitor-data-storage-sqlite bunx cap sync ``` ### Database Setup ```typescript import { CapacitorDataStorageSqlite } from '@capgo/capacitor-data-storage-sqlite'; class Database { private db = CapacitorDataStorageSqlite; private isOpen = false; async open() { if (this.isOpen) return; await this.db.openStore({ database: 'myapp', table: 'data', encrypted: false, mode: 'no-encryption', }); this.isOpen = true; } async set(key: string, value: any) { await this.open(); await this.db.set({ key, value: JSON.stringify(value), }); } async get(key: string): Promise { await this.open(); const result = await this.db.get({ key }); return result.value ? JSON.parse(result.value) : null; } async remove(key: string) { await this.open(); await this.db.remove({ key }); } async keys(): Promise { await this.open(); const result = await this.db.keys(); return result.keys; } } ``` ### Offline Data Repository ```typescript interface Entity { id: string; updatedAt: number; syncStatus: 'synced' | 'pending' | 'conflict'; } class OfflineRepository { constructor( private db: Database, private collection: string ) {} async getAll(): Promise { const keys = await this.db.keys(); const items: T[] = []; for (const key of keys) { if (key.startsWith(`${this.collection}:`)) { const item = await this.db.get(key); if (item) items.push(item); } } return items; } async getById(id: string): Promise { return this.db.get(`${this.collection}:${id}`); } async save(item: T): Promise { item.updatedAt = Date.now(); item.syncStatus = 'pending'; await this.db.set(`${this.collection}:${item.id}`, item); } async delete(id: string): Promise { // Soft delete - mark for sync const item = await this.getById(id); if (item) { item.syncStatus = 'pending'; (item as any).deleted = true; await this.db.set(`${this.collection}:${id}`, item); } } async getPending(): Promise { const all = await this.getAll(); return all.filter((item) => item.syncStatus === 'pending'); } async markSynced(id: string): Promise { const item = await this.getById(id); if (item) { item.syncStatus = 'synced'; await this.db.set(`${this.collection}:${id}`, item); } } } ``` ## Sync Manager ```typescript import { Network } from '@capacitor/network'; class SyncManager { private isSyncing = false; private syncQueue: Array<() => Promise> = []; constructor(private repositories: OfflineRepository[]) { this.setupNetworkListener(); } private setupNetworkListener() { Network.addListener('networkStatusChange', async (status) => { if (status.connected) { await this.syncAll(); } }); } async syncAll() { if (this.isSyncing) return; this.isSyncing = true; try { for (const repo of this.repositories) { await this.syncRepository(repo); } } finally { this.isSyncing = false; } } private async syncRepository(repo: OfflineRepository) { const pending = await repo.getPending(); for (const item of pending) { try { if ((item as any).deleted) { await this.deleteRemote(item); } else { await this.syncToRemote(item); } await repo.markSynced(item.id); } catch (error) { console.error('Sync failed for item:', item.id, error); // Keep as pending for retry } } // Pull remote changes await this.pullRemoteChanges(repo); } private async syncToRemote(item: any) { await fetch(`/api/${item.collection}/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item), }); } private async deleteRemote(item: any) { await fetch(`/api/${item.collection}/${item.id}`, { method: 'DELETE', }); } private async pullRemoteChanges(repo: OfflineRepository) { const lastSync = await this.getLastSyncTime(repo); const response = await fetch( `/api/${repo.collection}?since=${lastSync}` ); const remoteItems = await response.json(); for (const remoteItem of remoteItems) { const localItem = await repo.getById(remoteItem.id); if (!localItem) { // New item from server await repo.save({ ...remoteItem, syncStatus: 'synced' }); } else if (localItem.syncStatus === 'synced') { // No local changes - update from server await repo.save({ ...remoteItem, syncStatus: 'synced' }); } else { // Conflict - local has pending changes await this.resolveConflict(localItem, remoteItem, repo); } } await this.setLastSyncTime(repo, Date.now()); } private async resolveConflict( local: any, remote: any, repo: OfflineRepository ) { // Last-write-wins strategy if (local.updatedAt > remote.updatedAt) { // Keep local, re-sync to server local.syncStatus = 'pending'; await repo.save(local); } else { // Server wins await repo.save({ ...remote, syncStatus: 'synced' }); } } } ``` ## Service Worker Caching ### Register Service Worker ```typescript // src/main.ts if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js'); } ``` ### Service Worker with Workbox ```typescript // public/sw.js import { precacheAndRoute } from 'workbox-precaching'; import { registerRoute } from 'workbox-routing'; import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies'; // Precache static assets precacheAndRoute(self.__WB_MANIFEST); // Cache API responses registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api-cache', networkTimeoutSeconds: 5, }) ); // Cache images registerRoute( ({ request }) => request.destination === 'image', new CacheFirst({ cacheName: 'image-cache', plugins: [ { expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week }, }, ], }) ); // Cache fonts registerRoute( ({ request }) => request.destination === 'font', new CacheFirst({ cacheName: 'font-cache', }) ); ``` ## Optimistic UI Updates ```typescript class TodoService { constructor( private repo: OfflineRepository, private syncManager: SyncManager ) {} async addTodo(text: string): Promise { const todo: Todo = { id: crypto.randomUUID(), text, completed: false, updatedAt: Date.now(), syncStatus: 'pending', }; // Save locally immediately await this.repo.save(todo); // Trigger sync in background this.syncManager.syncAll().catch(console.error); return todo; } async toggleComplete(id: string): Promise { const todo = await this.repo.getById(id); if (!todo) throw new Error('Todo not found'); todo.completed = !todo.completed; await this.repo.save(todo); this.syncManager.syncAll().catch(console.error); return todo; } } ``` ## Queue Failed Requests ```typescript class RequestQueue { private queue: QueuedRequest[] = []; constructor(private storage: Database) { this.loadQueue(); } private async loadQueue() { this.queue = await this.storage.get('requestQueue') || []; } private async saveQueue() { await this.storage.set('requestQueue', this.queue); } async enqueue(request: QueuedRequest) { this.queue.push(request); await this.saveQueue(); } async processQueue() { const status = await Network.getStatus(); if (!status.connected) return; while (this.queue.length > 0) { const request = this.queue[0]; try { await fetch(request.url, { method: request.method, headers: request.headers, body: request.body, }); this.queue.shift(); await this.saveQueue(); } catch (error) { // Stop processing on failure break; } } } } ``` ## Best Practices ### 1. Show Sync Status ```tsx function SyncIndicator() { const { isOnline, pendingChanges, isSyncing } = useSyncStatus(); if (!isOnline) { return Offline; } if (isSyncing) { return Syncing...; } if (pendingChanges > 0) { return {pendingChanges} pending; } return Synced; } ``` ### 2. Handle Conflicts Gracefully ```typescript async function handleConflict(local: Todo, remote: Todo): Promise { // Option 1: Last write wins return local.updatedAt > remote.updatedAt ? local : remote; // Option 2: Merge changes return { ...remote, ...local, updatedAt: Math.max(local.updatedAt, remote.updatedAt), }; // Option 3: Ask user const choice = await showConflictDialog(local, remote); return choice === 'local' ? local : remote; } ``` ### 3. Validate Before Sync ```typescript function validateTodo(todo: Todo): boolean { if (!todo.id || !todo.text) return false; if (todo.text.length > 500) return false; return true; } async function syncTodo(todo: Todo) { if (!validateTodo(todo)) { throw new Error('Invalid todo'); } // Proceed with sync } ``` ## Resources - Capacitor Network: https://capacitorjs.com/docs/apis/network - Workbox: https://developer.chrome.com/docs/workbox - IndexedDB: https://developer.mozilla.org/docs/Web/API/IndexedDB_API - Offline First Manifesto: http://offlinefirst.org