Skip to main content
Technology & EngineeringLocal First556 lines

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.

Quick Summary29 lines
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 lines
Paste into your CLAUDE.md or agent config

IndexedDB 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:

StorageCapacityStructuredIndexedAsync
localStorage5-10 MBNo (strings)NoNo
sessionStorage5-10 MBNo (strings)NoNo
IndexedDB50%+ of diskYesYesYes
Cache APILargeNo (request/response)NoYes
OPFSLargeNo (files)NoYes

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

  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.

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

Get CLI access →

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.

Local First454L

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 First433L

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 First606L

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.

Local First285L

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.

Local First572L

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.

Local First471L