# IndexedDB Patterns > IndexedDB setup and usage patterns for offline-first applications. See [SKILL.md](../SKILL.md) for concepts. --- ## Pattern 12: Dexie.js Database Setup (v4.x) Initialize IndexedDB with Dexie.js 4.x for type-safe database operations. Dexie 4.0+ includes improved TypeScript support with `Entity` generics and class mappings. ### Database Schema Definition ```typescript // db/database.ts import Dexie, { type Table } from "dexie"; // Sync metadata interface for all syncable tables interface SyncMetadata { _syncStatus: "synced" | "pending" | "conflicted"; _lastModified: number; _localVersion: string; _serverVersion?: string; _deletedAt?: number; } // Entity interfaces interface Todo extends SyncMetadata { id: string; title: string; completed: boolean; userId: string; createdAt: number; } interface User extends SyncMetadata { id: string; email: string; name: string; preferences: UserPreferences; } interface UserPreferences { theme: "light" | "dark" | "system"; notifications: boolean; } // Sync queue operation interface QueuedOperation { id: string; type: "CREATE" | "UPDATE" | "DELETE" | "UPSERT"; collection: string; data: unknown; timestamp: number; retryCount: number; lastError?: string; } // Version constants const CURRENT_DB_VERSION = 3; class AppDatabase extends Dexie { // Declare tables with types todos!: Table; users!: Table; syncQueue!: Table; constructor() { super("myapp-db"); // Version 1: Initial schema this.version(1).stores({ todos: "id, userId, _syncStatus, _lastModified", users: "id, email, _syncStatus", syncQueue: "id, collection, timestamp", }); // Version 2: Add compound indexes this.version(2).stores({ todos: "id, userId, _syncStatus, _lastModified, [userId+completed]", users: "id, email, _syncStatus", syncQueue: "id, collection, timestamp, retryCount", }); // Version 3: Add deleted index for cleanup this.version(CURRENT_DB_VERSION).stores({ todos: "id, userId, _syncStatus, _lastModified, _deletedAt, [userId+completed]", users: "id, email, _syncStatus, _deletedAt", syncQueue: "id, collection, timestamp, retryCount", }); } // Helper method to clear all sync-related data async clearSyncState(): Promise { await this.transaction("rw", [this.todos, this.users], async () => { await this.todos.toCollection().modify({ _syncStatus: "pending", _serverVersion: undefined, }); await this.users.toCollection().modify({ _syncStatus: "pending", _serverVersion: undefined, }); }); } // Cleanup soft-deleted items older than retention period async cleanupTombstones( retentionMs: number = 30 * 24 * 60 * 60 * 1000, ): Promise { const threshold = Date.now() - retentionMs; let deletedCount = 0; await this.transaction("rw", [this.todos, this.users], async () => { // Only cleanup synced tombstones deletedCount += await this.todos .where("_deletedAt") .below(threshold) .and((item) => item._syncStatus === "synced") .delete(); deletedCount += await this.users .where("_deletedAt") .below(threshold) .and((item) => item._syncStatus === "synced") .delete(); }); return deletedCount; } } // Singleton database instance const db = new AppDatabase(); // Database initialization check async function initDatabase(): Promise { try { await db.open(); return db; } catch (error) { console.error("Failed to open database:", error); throw error; } } export { db, initDatabase, AppDatabase }; export type { Todo, User, UserPreferences, QueuedOperation, SyncMetadata }; ``` **Why good:** Type-safe table declarations, versioned migrations, indexed sync metadata fields, compound indexes for complex queries, tombstone cleanup method --- ## Pattern 13: Dexie Hooks for CRUD Operations Hooks that integrate Dexie with component state. ```typescript // hooks/use-dexie-crud.ts import { useCallback } from "react"; import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../db/database"; import type { Todo, SyncMetadata } from "../db/database"; // Sync status constants const SYNC_STATUS = { SYNCED: "synced", PENDING: "pending", CONFLICTED: "conflicted", } as const; // Generic CRUD hook factory function createCrudHooks( tableName: "todos" | "users", ) { function useGetAll(filter?: (item: T) => boolean) { return useLiveQuery(async () => { let collection = db[tableName].toCollection(); // Exclude soft-deleted const items = await collection.toArray(); let filtered = items.filter((item) => !item._deletedAt) as T[]; if (filter) { filtered = filtered.filter(filter); } // Sort by last modified, newest first return filtered.sort((a, b) => b._lastModified - a._lastModified); }, [filter]); } function useGetById(id: string | undefined) { return useLiveQuery(async () => { if (!id) return null; const item = await db[tableName].get(id); if (item?._deletedAt) return null; return item as T | null; }, [id]); } return { useGetAll, useGetById }; } // Create Todo-specific hooks const todoHooks = createCrudHooks("todos"); function useTodos(userId?: string) { return todoHooks.useGetAll( userId ? (todo) => todo.userId === userId : undefined, ); } function useTodo(id: string | undefined) { return todoHooks.useGetById(id); } // Mutation functions (not hooks, just async functions) async function createTodo(title: string, userId: string): Promise { const todo: Todo = { id: crypto.randomUUID(), title, completed: false, userId, createdAt: Date.now(), _syncStatus: SYNC_STATUS.PENDING, _lastModified: Date.now(), _localVersion: crypto.randomUUID(), }; await db.todos.add(todo); return todo; } async function updateTodo( id: string, updates: Partial>, ): Promise { await db.todos.update(id, { ...updates, _syncStatus: SYNC_STATUS.PENDING, _lastModified: Date.now(), _localVersion: crypto.randomUUID(), }); } async function deleteTodo(id: string): Promise { // Soft delete await db.todos.update(id, { _deletedAt: Date.now(), _syncStatus: SYNC_STATUS.PENDING, _lastModified: Date.now(), _localVersion: crypto.randomUUID(), }); } // Batch operations async function markTodosComplete(ids: string[]): Promise { await db.transaction("rw", db.todos, async () => { for (const id of ids) { await updateTodo(id, { completed: true }); } }); } async function clearCompletedTodos(userId: string): Promise { const completedTodos = await db.todos .where("[userId+completed]") .equals([userId, 1]) // 1 = true in IndexedDB .toArray(); await db.transaction("rw", db.todos, async () => { for (const todo of completedTodos) { await deleteTodo(todo.id); } }); } export { useTodos, useTodo, createTodo, updateTodo, deleteTodo, markTodosComplete, clearCompletedTodos, }; ``` **Why good:** useLiveQuery provides reactive updates, soft deletes maintain sync integrity, batch operations use transactions, type-safe throughout --- ## Pattern 14: idb Library Alternative (v8.x) Using the lighter-weight idb library (~1.2KB brotli) instead of Dexie. Version 8.0.3 is current stable. **CRITICAL:** Do not await non-IndexedDB operations (like `fetch()`) mid-transaction. Transactions auto-close when control returns to the event loop without pending requests. ```typescript // db/idb-database.ts import { openDB, type DBSchema, type IDBPDatabase } from "idb"; // Schema definition interface AppDBSchema extends DBSchema { todos: { key: string; value: { id: string; title: string; completed: boolean; userId: string; createdAt: number; _syncStatus: "synced" | "pending" | "conflicted"; _lastModified: number; _localVersion: string; _serverVersion?: string; _deletedAt?: number; }; indexes: { "by-user": string; "by-sync-status": string; "by-deleted": number; }; }; syncQueue: { key: string; value: { id: string; type: "CREATE" | "UPDATE" | "DELETE"; collection: string; data: unknown; timestamp: number; retryCount: number; }; indexes: { "by-timestamp": number; }; }; } // Database version const DB_VERSION = 1; const DB_NAME = "myapp-idb"; async function initIdbDatabase(): Promise> { return openDB(DB_NAME, DB_VERSION, { upgrade(db, oldVersion, newVersion, transaction) { // Version 1: Initial schema if (oldVersion < 1) { // Todos store const todosStore = db.createObjectStore("todos", { keyPath: "id" }); todosStore.createIndex("by-user", "userId"); todosStore.createIndex("by-sync-status", "_syncStatus"); todosStore.createIndex("by-deleted", "_deletedAt"); // Sync queue store const syncStore = db.createObjectStore("syncQueue", { keyPath: "id" }); syncStore.createIndex("by-timestamp", "timestamp"); } // Future versions would add more conditions: // if (oldVersion < 2) { ... } }, blocked() { console.warn("Database upgrade blocked by other tabs"); }, blocking() { // Close connection in other tabs console.warn("Blocking other tabs from upgrade"); }, }); } // Repository wrapper for idb class IdbTodoRepository { constructor(private db: IDBPDatabase) {} async getAll(userId: string): Promise { const todos = await this.db.getAllFromIndex("todos", "by-user", userId); return todos.filter((todo) => !todo._deletedAt); } async get(id: string): Promise { const todo = await this.db.get("todos", id); if (todo?._deletedAt) return null; return todo ?? null; } async save(todo: AppDBSchema["todos"]["value"]): Promise { await this.db.put("todos", { ...todo, _syncStatus: "pending", _lastModified: Date.now(), _localVersion: crypto.randomUUID(), }); } async delete(id: string): Promise { const existing = await this.db.get("todos", id); if (!existing) return; await this.db.put("todos", { ...existing, _deletedAt: Date.now(), _syncStatus: "pending", _lastModified: Date.now(), _localVersion: crypto.randomUUID(), }); } async getPending(): Promise { return this.db.getAllFromIndex("todos", "by-sync-status", "pending"); } async markSynced(id: string, serverVersion: string): Promise { const todo = await this.db.get("todos", id); if (!todo) return; await this.db.put("todos", { ...todo, _syncStatus: "synced", _serverVersion: serverVersion, }); } } export { initIdbDatabase, IdbTodoRepository }; export type { AppDBSchema }; ``` **Why good:** Lighter weight than Dexie (~1.2KB vs ~22KB), strongly typed with DBSchema, proper index usage, migration support via upgrade function, promises instead of event listeners **Version Notes:** - idb 8.0.3: Current stable version, CDN available at `https://cdn.jsdelivr.net/npm/idb@8/+esm` - For simple key-value storage: Use `idb-keyval` (~295 bytes) instead --- ## Pattern 15: Database Migration with Data Transform Handle complex schema migrations that require data transformation. ```typescript // db/migrations.ts import Dexie from "dexie"; import { db } from "./database"; // Migration version constants const MIGRATION_VERSIONS = { INITIAL: 1, ADD_SYNC_METADATA: 2, NORMALIZE_TIMESTAMPS: 3, } as const; // Configure migrations with data transforms db.version(MIGRATION_VERSIONS.INITIAL).stores({ todos: "id, userId", }); db.version(MIGRATION_VERSIONS.ADD_SYNC_METADATA) .stores({ todos: "id, userId, _syncStatus, _lastModified", }) .upgrade(async (tx) => { // Add sync metadata to existing todos const todos = tx.table("todos"); await todos.toCollection().modify((todo) => { if (!todo._syncStatus) { todo._syncStatus = "synced"; // Existing data is assumed synced todo._lastModified = Date.now(); todo._localVersion = crypto.randomUUID(); } }); }); db.version(MIGRATION_VERSIONS.NORMALIZE_TIMESTAMPS) .stores({ todos: "id, userId, _syncStatus, _lastModified, createdAt", }) .upgrade(async (tx) => { // Normalize timestamps from ISO strings to numbers const todos = tx.table("todos"); await todos.toCollection().modify((todo) => { if (typeof todo.createdAt === "string") { todo.createdAt = new Date(todo.createdAt).getTime(); } if (typeof todo._lastModified === "string") { todo._lastModified = new Date(todo._lastModified).getTime(); } }); }); // Export migration status check async function checkMigrationStatus(): Promise<{ currentVersion: number; latestVersion: number; needsMigration: boolean; }> { const currentVersion = db.verno; const latestVersion = MIGRATION_VERSIONS.NORMALIZE_TIMESTAMPS; return { currentVersion, latestVersion, needsMigration: currentVersion < latestVersion, }; } export { checkMigrationStatus, MIGRATION_VERSIONS }; ``` **Why good:** Named version constants for clarity, upgrade functions transform existing data, migrations are idempotent (check before modify), status check for debugging --- ## Pattern 16: Multi-Tab Coordination Handle multiple browser tabs accessing the same IndexedDB. ```typescript // db/tab-coordinator.ts // Named constants const LOCK_TIMEOUT_MS = 30000; const HEARTBEAT_INTERVAL_MS = 5000; const STALE_LOCK_THRESHOLD_MS = 10000; interface TabLock { tabId: string; timestamp: number; operation: string; } class TabCoordinator { private readonly tabId = crypto.randomUUID(); private readonly channel: BroadcastChannel; private activeLocks = new Map(); private heartbeatInterval: ReturnType | null = null; constructor() { this.channel = new BroadcastChannel("app-tab-coordinator"); this.setupListeners(); this.startHeartbeat(); } private setupListeners(): void { this.channel.onmessage = (event) => { const { type, payload } = event.data; switch (type) { case "LOCK_ACQUIRED": this.activeLocks.set(payload.operation, payload); break; case "LOCK_RELEASED": this.activeLocks.delete(payload.operation); break; case "HEARTBEAT": // Update timestamp for existing locks from this tab if ( this.activeLocks.get(payload.operation)?.tabId === payload.tabId ) { this.activeLocks.set(payload.operation, payload); } break; case "DB_CHANGED": // Another tab made changes - emit event for UI refresh window.dispatchEvent( new CustomEvent("database-changed", { detail: payload }), ); break; } }; } private startHeartbeat(): void { this.heartbeatInterval = setInterval(() => { // Send heartbeat for any locks this tab holds for (const [operation, lock] of this.activeLocks) { if (lock.tabId === this.tabId) { this.channel.postMessage({ type: "HEARTBEAT", payload: { ...lock, timestamp: Date.now() }, }); } } // Clean up stale locks this.cleanupStaleLocks(); }, HEARTBEAT_INTERVAL_MS); } private cleanupStaleLocks(): void { const now = Date.now(); for (const [operation, lock] of this.activeLocks) { if (now - lock.timestamp > STALE_LOCK_THRESHOLD_MS) { this.activeLocks.delete(operation); } } } async acquireLock(operation: string): Promise { const existingLock = this.activeLocks.get(operation); // Check if lock exists and is not stale if ( existingLock && Date.now() - existingLock.timestamp < STALE_LOCK_THRESHOLD_MS ) { return false; // Lock held by another tab } const lock: TabLock = { tabId: this.tabId, timestamp: Date.now(), operation, }; this.activeLocks.set(operation, lock); this.channel.postMessage({ type: "LOCK_ACQUIRED", payload: lock }); return true; } releaseLock(operation: string): void { const lock = this.activeLocks.get(operation); if (lock?.tabId === this.tabId) { this.activeLocks.delete(operation); this.channel.postMessage({ type: "LOCK_RELEASED", payload: { operation }, }); } } notifyDatabaseChange(collection: string, id: string): void { this.channel.postMessage({ type: "DB_CHANGED", payload: { collection, id, tabId: this.tabId }, }); } destroy(): void { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } // Release all locks held by this tab for (const [operation, lock] of this.activeLocks) { if (lock.tabId === this.tabId) { this.releaseLock(operation); } } this.channel.close(); } } // Singleton instance const tabCoordinator = new TabCoordinator(); export { tabCoordinator, TabCoordinator }; export type { TabLock }; ``` **Why good:** BroadcastChannel for inter-tab communication, heartbeat keeps locks fresh, stale lock cleanup prevents deadlocks, notifies other tabs of changes --- ## Pattern 17: IndexedDB Storage Quota Management Monitor and manage storage usage to prevent quota exceeded errors. ```typescript // db/storage-manager.ts import { useState, useEffect } from "react"; // Constants const STORAGE_WARNING_THRESHOLD = 0.8; // 80% const STORAGE_CRITICAL_THRESHOLD = 0.95; // 95% const MIN_FREE_SPACE_MB = 50; interface StorageInfo { usedBytes: number; totalBytes: number; percentUsed: number; status: "ok" | "warning" | "critical"; } async function getStorageInfo(): Promise { if (!navigator.storage?.estimate) { // Fallback for browsers without StorageManager return { usedBytes: 0, totalBytes: 0, percentUsed: 0, status: "ok", }; } const estimate = await navigator.storage.estimate(); const used = estimate.usage ?? 0; const total = estimate.quota ?? 0; const percentUsed = total > 0 ? used / total : 0; let status: StorageInfo["status"] = "ok"; if (percentUsed >= STORAGE_CRITICAL_THRESHOLD) { status = "critical"; } else if (percentUsed >= STORAGE_WARNING_THRESHOLD) { status = "warning"; } return { usedBytes: used, totalBytes: total, percentUsed, status, }; } async function requestPersistentStorage(): Promise { if (!navigator.storage?.persist) { return false; } // Check if already persisted const isPersisted = await navigator.storage.persisted(); if (isPersisted) { return true; } // Request persistence return navigator.storage.persist(); } // Cleanup strategies when storage is low interface CleanupResult { freedBytes: number; itemsRemoved: number; } async function performStorageCleanup( db: LocalDatabase, ): Promise { let freedBytes = 0; let itemsRemoved = 0; // Strategy 1: Remove synced tombstones older than 7 days const TOMBSTONE_AGE_MS = 7 * 24 * 60 * 60 * 1000; const tombstoneThreshold = Date.now() - TOMBSTONE_AGE_MS; const tombstones = await db.todos .where("_deletedAt") .below(tombstoneThreshold) .and((item) => item._syncStatus === "synced") .toArray(); for (const item of tombstones) { await db.todos.delete(item.id); itemsRemoved++; freedBytes += estimateObjectSize(item); } // Strategy 2: Clear old cache entries (if using cache table) // Add more cleanup strategies as needed return { freedBytes, itemsRemoved }; } // Rough estimate of object size in bytes function estimateObjectSize(obj: unknown): number { return new Blob([JSON.stringify(obj)]).size; } // Hook for React components const STORAGE_POLL_INTERVAL_MS = 60_000; function useStorageInfo() { const [info, setInfo] = useState(null); useEffect(() => { getStorageInfo().then(setInfo); const interval = setInterval(() => { getStorageInfo().then(setInfo); }, STORAGE_POLL_INTERVAL_MS); return () => clearInterval(interval); }, []); return info; } export { getStorageInfo, requestPersistentStorage, performStorageCleanup, useStorageInfo, }; export type { StorageInfo, CleanupResult }; ``` **Why good:** Proactive monitoring prevents quota errors, persistent storage prevents data eviction, cleanup strategies free space intelligently, size estimation for tracking