indexeddb-patterns
Teaches IndexedDB patterns for local-first web applications, using Dexie.js as the primary wrapper library. Covers schema design and versioning, creating indexes for efficient queries, transaction patterns, performance optimization (bulk operations, pagination, lazy loading), migration strategies for schema evolution, storage quota management, data export and import, and integration patterns with sync engines and reactive frameworks.
IndexedDB is the browser's built-in database for storing structured data locally. Dexie.js makes it usable. ## Key Points - **Large datasets** (hundreds of MB to GB, depending on browser) - **Structured data** with indexes - **Transactions** for atomic operations - **Asynchronous API** that doesn't block the UI thread 1. **Never modify a previous version definition** — always add a new version. 2. **Keep upgrade functions idempotent** — they may run multiple times if interrupted. 3. **Test migrations** with data from each previous version. 4. **Version numbers must be sequential integers** starting from 1. 5. **Only indexed fields appear in stores()** — you can store any fields in a record. ## Quick Example ```bash npm install dexie ``` ``` ++id Auto-incrementing primary key id Explicit primary key (you provide the value) &email Unique index *tags Multi-entry index (for arrays — each element is indexed) [a+b] Compound index (index on two fields together) ```
skilldb get local-first-skills/indexeddb-patternsFull skill: 556 linesIndexedDB Patterns
IndexedDB is the browser's built-in database for storing structured data locally. Dexie.js makes it usable.
Why IndexedDB
IndexedDB is the only browser storage API that supports:
- Large datasets (hundreds of MB to GB, depending on browser)
- Structured data with indexes
- Transactions for atomic operations
- Asynchronous API that doesn't block the UI thread
Comparison:
| Storage | Capacity | Structured | Indexed | Async |
|---|---|---|---|---|
| localStorage | 5-10 MB | No (strings) | No | No |
| sessionStorage | 5-10 MB | No (strings) | No | No |
| IndexedDB | 50%+ of disk | Yes | Yes | Yes |
| Cache API | Large | No (request/response) | No | Yes |
| OPFS | Large | No (files) | No | Yes |
Dexie.js: The IndexedDB Wrapper
The raw IndexedDB API is verbose and callback-heavy. Dexie.js provides a clean Promise-based API.
npm install dexie
Define a Database
import Dexie, { type Table } from 'dexie';
interface Todo {
id?: number; // auto-incremented
title: string;
completed: boolean;
createdAt: Date;
tags: string[];
projectId: string;
}
interface Project {
id: string;
name: string;
color: string;
createdAt: Date;
}
class AppDatabase extends Dexie {
todos!: Table<Todo>;
projects!: Table<Project>;
constructor() {
super('myapp');
this.version(1).stores({
// Only indexed/searchable fields go here, not all fields
todos: '++id, projectId, completed, createdAt, *tags',
projects: 'id, name',
});
}
}
export const db = new AppDatabase();
Index Syntax
++id Auto-incrementing primary key
id Explicit primary key (you provide the value)
&email Unique index
*tags Multi-entry index (for arrays — each element is indexed)
[a+b] Compound index (index on two fields together)
CRUD Operations
Create
// Single insert
const id = await db.todos.add({
title: 'Buy milk',
completed: false,
createdAt: new Date(),
tags: ['groceries'],
projectId: 'home',
});
// Bulk insert (much faster than individual adds)
await db.todos.bulkAdd([
{ title: 'Task 1', completed: false, createdAt: new Date(), tags: [], projectId: 'work' },
{ title: 'Task 2', completed: false, createdAt: new Date(), tags: [], projectId: 'work' },
{ title: 'Task 3', completed: false, createdAt: new Date(), tags: [], projectId: 'home' },
]);
// Put (insert or replace by primary key)
await db.projects.put({ id: 'work', name: 'Work', color: '#2196f3', createdAt: new Date() });
Read
// Get by primary key
const todo = await db.todos.get(1);
// Get all
const allTodos = await db.todos.toArray();
// Query by index
const workTodos = await db.todos
.where('projectId')
.equals('work')
.toArray();
// Compound queries
const recentActive = await db.todos
.where('completed')
.equals(0) // false stored as 0
.and((todo) => todo.createdAt > lastWeek)
.toArray();
// Multi-entry index query (find todos with a specific tag)
const urgentTodos = await db.todos
.where('tags')
.equals('urgent')
.toArray();
// Sorting
const sorted = await db.todos
.orderBy('createdAt')
.reverse()
.toArray();
// Pagination
const page = await db.todos
.orderBy('createdAt')
.reverse()
.offset(20)
.limit(10)
.toArray();
Update
// Update by primary key
await db.todos.update(1, { completed: true });
// Modify matching records
await db.todos
.where('projectId')
.equals('old-project')
.modify({ projectId: 'new-project' });
// Modify with a function
await db.todos
.where('completed')
.equals(1)
.modify((todo) => {
todo.tags.push('archived');
});
// Bulk update
await db.todos.bulkPut([
{ id: 1, title: 'Updated 1', completed: true, createdAt: new Date(), tags: [], projectId: 'work' },
{ id: 2, title: 'Updated 2', completed: false, createdAt: new Date(), tags: [], projectId: 'home' },
]);
Delete
// Delete by primary key
await db.todos.delete(1);
// Delete matching records
await db.todos
.where('completed')
.equals(1)
.delete();
// Bulk delete
await db.todos.bulkDelete([1, 2, 3]);
// Clear entire table
await db.todos.clear();
Transactions
Group operations into atomic transactions. If any operation fails, all are rolled back.
// Read-write transaction across multiple tables
await db.transaction('rw', [db.projects, db.todos], async () => {
// Delete project and all its todos atomically
const projectId = 'old-project';
await db.todos.where('projectId').equals(projectId).delete();
await db.projects.delete(projectId);
});
// Transaction with return value
const count = await db.transaction('r', db.todos, async () => {
const active = await db.todos.where('completed').equals(0).count();
const total = await db.todos.count();
return { active, total };
});
Schema Versioning and Migrations
Dexie handles schema migrations through version numbers. Each version can add, remove, or modify indexes.
class AppDatabase extends Dexie {
todos!: Table<Todo>;
projects!: Table<Project>;
labels!: Table<Label>;
constructor() {
super('myapp');
// Version 1: initial schema
this.version(1).stores({
todos: '++id, projectId, completed',
projects: 'id, name',
});
// Version 2: add tags index and labels table
this.version(2).stores({
todos: '++id, projectId, completed, *tags',
projects: 'id, name',
labels: '++id, name, &slug',
});
// Version 3: add createdAt index, data migration
this.version(3)
.stores({
todos: '++id, projectId, completed, *tags, createdAt',
projects: 'id, name',
labels: '++id, name, &slug',
})
.upgrade(async (tx) => {
// Data migration: add createdAt to existing todos
await tx.table('todos').toCollection().modify((todo) => {
if (!todo.createdAt) {
todo.createdAt = new Date();
}
});
});
// Version 4: remove a table (set to null)
this.version(4).stores({
todos: '++id, projectId, completed, *tags, createdAt',
projects: 'id, name, createdAt',
labels: null, // deletes the labels table
});
}
}
Migration Best Practices
- Never modify a previous version definition — always add a new version.
- Keep upgrade functions idempotent — they may run multiple times if interrupted.
- Test migrations with data from each previous version.
- Version numbers must be sequential integers starting from 1.
- Only indexed fields appear in stores() — you can store any fields in a record.
Performance Patterns
Bulk Operations
// BAD: individual operations in a loop (slow)
for (const item of items) {
await db.todos.add(item); // each triggers a separate transaction
}
// GOOD: bulk operation (single transaction)
await db.todos.bulkAdd(items);
// GOOD: explicit transaction for mixed operations
await db.transaction('rw', db.todos, async () => {
for (const item of items) {
await db.todos.add(item);
}
});
Cursor-Based Pagination
// More efficient than offset/limit for large datasets
async function getCursorPage(lastId: number | undefined, pageSize: number) {
let query = db.todos.orderBy('id');
if (lastId !== undefined) {
query = query.where('id').above(lastId);
}
return query.limit(pageSize).toArray();
}
// Usage
let lastId: number | undefined;
const page1 = await getCursorPage(undefined, 20);
lastId = page1[page1.length - 1]?.id;
const page2 = await getCursorPage(lastId, 20);
Count Without Loading Data
// BAD: loads all records into memory
const count = (await db.todos.toArray()).length;
// GOOD: count at the database level
const count = await db.todos.where('completed').equals(0).count();
Use Web Workers for Heavy Queries
// worker.ts
import { db } from './database';
self.onmessage = async (event) => {
const { type, params } = event.data;
if (type === 'search') {
const results = await db.todos
.where('tags')
.anyOf(params.tags)
.toArray();
self.postMessage({ type: 'searchResults', data: results });
}
};
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.postMessage({ type: 'search', params: { tags: ['urgent'] } });
worker.onmessage = (event) => {
if (event.data.type === 'searchResults') {
updateUI(event.data.data);
}
};
Storage Limits and Quota Management
// Check available storage
async function checkStorage() {
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate();
const usedMB = (usage || 0) / (1024 * 1024);
const quotaMB = (quota || 0) / (1024 * 1024);
console.log(`Using ${usedMB.toFixed(1)} MB of ${quotaMB.toFixed(1)} MB`);
return { usage: usage || 0, quota: quota || 0 };
}
return null;
}
// Request persistent storage (prevents browser from evicting data)
async function requestPersistence() {
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log('Persistent storage:', granted ? 'granted' : 'denied');
return granted;
}
return false;
}
// Eviction strategy: remove old completed todos when storage is tight
async function evictOldData() {
const { usage, quota } = (await checkStorage()) || {};
if (usage && quota && usage / quota > 0.8) {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await db.todos
.where('completed').equals(1)
.and((todo) => todo.createdAt < thirtyDaysAgo)
.delete();
}
}
Data Export and Import
// Export entire database to JSON
async function exportDatabase(): Promise<string> {
const data: Record<string, any[]> = {};
for (const table of db.tables) {
data[table.name] = await table.toArray();
}
return JSON.stringify(data, null, 2);
}
// Import from JSON
async function importDatabase(json: string) {
const data = JSON.parse(json);
await db.transaction('rw', db.tables, async () => {
for (const [tableName, records] of Object.entries(data)) {
const table = db.table(tableName);
await table.clear();
await table.bulkAdd(records as any[]);
}
});
}
// Download as file
function downloadExport(json: string) {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
// File picker import
async function importFromFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async () => {
const file = input.files?.[0];
if (file) {
const json = await file.text();
await importDatabase(json);
}
};
input.click();
}
Reactive Queries with Dexie
Dexie supports live queries that re-run whenever the underlying data changes.
import { useLiveQuery } from 'dexie-react-hooks';
function TodoList({ projectId }: { projectId: string }) {
// This re-runs automatically when todos table changes
const todos = useLiveQuery(
() => db.todos
.where('projectId')
.equals(projectId)
.sortBy('createdAt'),
[projectId] // dependencies
);
if (!todos) return <div>Loading...</div>;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
// Computed live queries
function TodoStats() {
const stats = useLiveQuery(async () => {
const [total, completed] = await Promise.all([
db.todos.count(),
db.todos.where('completed').equals(1).count(),
]);
return { total, completed, active: total - completed };
});
if (!stats) return null;
return <p>{stats.active} active / {stats.total} total</p>;
}
Integration with Sync Engines
IndexedDB is the local persistence layer for most sync engines.
// Pattern: IndexedDB as the local cache for a sync engine
class SyncedDatabase {
private db: AppDatabase;
private pendingChanges: Table<PendingChange>;
async applyLocal(change: Change) {
await this.db.transaction('rw', [this.db.todos, this.db.pendingChanges], async () => {
// Apply to local data
await this.db.todos.put(change.data);
// Queue for sync
await this.db.pendingChanges.add({
table: 'todos',
operation: change.type,
data: change.data,
timestamp: Date.now(),
});
});
}
async applyRemote(changes: Change[]) {
await this.db.transaction('rw', this.db.todos, async () => {
for (const change of changes) {
if (change.type === 'delete') {
await this.db.todos.delete(change.data.id);
} else {
await this.db.todos.put(change.data);
}
}
});
}
async getPendingChanges() {
return this.db.pendingChanges.toArray();
}
async clearPendingChanges(ids: number[]) {
await this.db.pendingChanges.bulkDelete(ids);
}
}
Install this skill directly: skilldb add local-first-skills
Related Skills
crdt-fundamentals
Teaches Conflict-free Replicated Data Types (CRDTs), the mathematical foundation for local-first sync. Covers how CRDTs guarantee eventual consistency without coordination, the difference between state-based and operation-based CRDTs, and practical implementations of G-Counter, PN-Counter, LWW-Register, OR-Set, G-Set, and RGA (Replicated Growable Array). Includes causal ordering, vector clocks, and guidance on choosing the right CRDT for your data model.
electric-sql
Teaches ElectricSQL, a Postgres-backed local-first sync framework. Covers the Electric architecture where Postgres is the source of truth and data syncs to local SQLite databases on client devices via shape-based partial replication. Includes shape definitions, live queries, offline-first patterns, conflict resolution with rich CRDTs, integration with React and Expo (React Native), deployment patterns, and migration strategies.
local-first-auth
Teaches authentication and authorization patterns for local-first applications that must work offline. Covers offline-capable auth with cached tokens, permission sync and local enforcement, encrypted local storage for sensitive data, key management with device-bound keys, device authorization and revocation, multi-device identity linking, end-to-end encryption for synced data, and secure patterns for handling auth in disconnected environments.
local-first-fundamentals
Teaches the local-first software paradigm where applications store data on the user's device, work fully offline, and sync to peers or servers when connectivity is available. Covers the spectrum from cloud-first to offline-first to local-first, core benefits (instant UX, offline capability, data ownership, privacy), key challenges (conflict resolution, sync complexity, storage limits), architectural patterns, and decision frameworks for when local-first is the right choice.
sync-engine-architecture
Teaches how to design and build a sync engine for local-first applications. Covers the operation log as the foundation, conflict resolution strategies (last-write-wins, operational transform, CRDTs), server reconciliation patterns, partial sync for large datasets, bandwidth optimization techniques, version vectors and causal consistency, clock synchronization, and practical implementation patterns with code examples.
yjs-sync
Teaches building local-first collaborative applications with Yjs, the most widely adopted CRDT library for JavaScript. Covers the Y.Doc document model, shared types (Y.Map, Y.Array, Y.Text, Y.XmlFragment), the awareness protocol for presence and cursors, persistence and sync providers (WebSocket, WebRTC, IndexedDB), integrating with editors like ProseMirror/TipTap/CodeMirror/Monaco, undo/redo management, and performance optimization patterns.