Skip to content

Offline-First Patterns

IndexedDB storage and service worker caching strategies.


Overview

On Book Pro is designed offline-first, meaning:

  • Core features work without internet
  • Changes sync automatically when reconnected
  • Local data persists across browser sessions

Storage Layers

┌─────────────────────────────────────────┐
│           Application State             │
│            (Zustand Store)              │
└─────────────────┬───────────────────────┘
                  │ persist middleware

┌─────────────────────────────────────────┐
│            LocalStorage                 │
│      (JSON state, ~5MB limit)           │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│            IndexedDB                    │
│    (Blobs, audio, large data)           │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│          Service Worker                 │
│   (Static assets, API responses)        │
└─────────────────────────────────────────┘

IndexedDB Patterns

Database Setup

typescript
// src/utils/indexed-db.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';

interface OnBookDB extends DBSchema {
    'sound-blobs': {
        key: string;
        value: Blob;
    };
    'file-cache': {
        key: string;
        value: {
            blob: Blob;
            cachedAt: number;
            expiresAt: number;
        };
    };
    'offline-queue': {
        key: string;
        value: {
            action: string;
            payload: unknown;
            timestamp: number;
        };
    };
}

export const initDB = async (): Promise<IDBPDatabase<OnBookDB>> => {
    return openDB<OnBookDB>('on-book-pro', 1, {
        upgrade(db) {
            db.createObjectStore('sound-blobs');
            db.createObjectStore('file-cache');
            db.createObjectStore('offline-queue');
        },
    });
};

Storing Large Files

typescript
export const cacheBlob = async (
    store: 'sound-blobs' | 'file-cache',
    key: string,
    blob: Blob
) => {
    const db = await initDB();
    await db.put(store, blob, key);
};

export const getBlob = async (
    store: 'sound-blobs' | 'file-cache',
    key: string
): Promise<Blob | undefined> => {
    const db = await initDB();
    return db.get(store, key);
};

Sync Queue

Queue actions while offline for later execution:

typescript
// src/utils/sync-queue.ts
interface QueuedAction {
    id: string;
    action: string;
    payload: unknown;
    timestamp: number;
    retryCount: number;
}

export const enqueueAction = async (action: string, payload: unknown) => {
    const db = await initDB();
    const queuedAction: QueuedAction = {
        id: crypto.randomUUID(),
        action,
        payload,
        timestamp: Date.now(),
        retryCount: 0,
    };
    
    await db.put('offline-queue', queuedAction, queuedAction.id);
};

export const processQueue = async () => {
    const db = await initDB();
    const tx = db.transaction('offline-queue', 'readwrite');
    const store = tx.objectStore('offline-queue');
    
    let cursor = await store.openCursor();
    
    while (cursor) {
        const action = cursor.value;
        
        try {
            await executeAction(action);
            await cursor.delete();
        } catch (error) {
            // Increment retry count
            action.retryCount++;
            if (action.retryCount < 3) {
                await cursor.update(action);
            } else {
                // Max retries reached, log and remove
                console.error('Action failed permanently:', action);
                await cursor.delete();
            }
        }
        
        cursor = await cursor.continue();
    }
};

// Process queue when coming back online
window.addEventListener('online', processQueue);

Service Worker Strategy

Workbox Configuration

javascript
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
    plugins: [
        VitePWA({
            strategies: 'generateSW',
            workbox: {
                globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
                runtimeCaching: [
                    {
                        urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
                        handler: 'CacheFirst',
                        options: {
                            cacheName: 'google-fonts-cache',
                            expiration: {
                                maxEntries: 10,
                                maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
                            },
                        },
                    },
                    {
                        urlPattern: /^https:\/\/firebasestorage\.googleapis\.com\/.*/i,
                        handler: 'StaleWhileRevalidate',
                        options: {
                            cacheName: 'firebase-storage-cache',
                            expiration: {
                                maxEntries: 100,
                                maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week
                            },
                        },
                    },
                ],
            },
        }),
    ],
});

Conflict Resolution

When the same data is modified offline on multiple devices:

Last-Write-Wins (Simple)

typescript
// Compare timestamps, keep newer
const resolveConflict = (local: Item, remote: Item): Item => {
    return local.updatedAt > remote.updatedAt ? local : remote;
};

Merge Strategy (Complex)

typescript
// For array data, merge and deduplicate
const mergeItems = (local: Item[], remote: Item[]): Item[] => {
    const merged = new Map<string, Item>();
    
    // Add remote items
    remote.forEach(item => merged.set(item.id, item));
    
    // Overlay local items (local wins on conflict)
    local.forEach(item => {
        const existing = merged.get(item.id);
        if (!existing || item.updatedAt > existing.updatedAt) {
            merged.set(item.id, item);
        }
    });
    
    return Array.from(merged.values());
};

Connection Status

typescript
// src/hooks/useOnlineStatus.ts
export const useOnlineStatus = () => {
    const [isOnline, setIsOnline] = useState(navigator.onLine);
    
    useEffect(() => {
        const handleOnline = () => setIsOnline(true);
        const handleOffline = () => setIsOnline(false);
        
        window.addEventListener('online', handleOnline);
        window.addEventListener('offline', handleOffline);
        
        return () => {
            window.removeEventListener('online', handleOnline);
            window.removeEventListener('offline', handleOffline);
        };
    }, []);
    
    return isOnline;
};

UI Indicator

tsx
export const OfflineBanner = () => {
    const isOnline = useOnlineStatus();
    
    if (isOnline) return null;
    
    return (
        <div className="bg-amber-500 text-white text-center py-1 text-sm">
            You're offline. Changes will sync when reconnected.
        </div>
    );
};

Best Practices

  1. Persist critical state — Use Zustand's persist middleware
  2. Queue mutations — Don't lose user actions while offline
  3. Show sync status — Let users know when data is syncing
  4. Handle large files separately — Use IndexedDB for blobs
  5. Test offline regularly — Use DevTools Network throttling

Storage Limits

StorageTypical LimitUsage
LocalStorage~5MBApp state JSON
IndexedDB~50GBAudio blobs, file cache
Service Worker Cache~50MBStatic assets

Last updated: January 2026