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
- Persist critical state — Use Zustand's persist middleware
- Queue mutations — Don't lose user actions while offline
- Show sync status — Let users know when data is syncing
- Handle large files separately — Use IndexedDB for blobs
- Test offline regularly — Use DevTools Network throttling
Storage Limits
| Storage | Typical Limit | Usage |
|---|---|---|
| LocalStorage | ~5MB | App state JSON |
| IndexedDB | ~50GB | Audio blobs, file cache |
| Service Worker Cache | ~50MB | Static assets |
Last updated: January 2026