IndexedDB and OPFS

Use IndexedDB for browser-local structured data. Use OPFS for browser-local file-like data when performance or worker access matters.

import Dexie, { type Table } from "dexie";

type Todo = {
  id: string;
  title: string;
  completed: boolean;
  updatedAt: string;
};

class AppDb extends Dexie {
  todos!: Table<Todo, string>;

  constructor() {
    super("local-first-demo");
    this.version(1).stores({
      todos: "id, updatedAt, completed",
    });
  }
}

export const db = new AppDb();

IndexedDB Strengths

  • asynchronous browser-native database
  • transactions and indexes
  • stores structured clone data, including blobs
  • available in Web Workers
  • same-origin scoped

Failure Modes To Design For

FailureWhat happensGate
quota exceededwrite fails with quota errorshow storage pressure and export path
evictionbest-effort data can disappear under pressurerequest persistence for critical offline data
blocked upgradeanother tab holds old DB versionprompt user to close/reload other tabs
private mode cleanupdata may clear at session endwarn before treating it as durable
long main-thread exportUI freezesrun large export/import in a worker

Dexie Upgrades

db.version(2).stores({
  todos: "id, updatedAt, completed, listId",
}).upgrade(async (tx) => {
  await tx.table("todos").toCollection().modify((todo) => {
    todo.listId = "inbox";
  });
});

Test upgrades with real old data. Do not rely on empty-database upgrades.

OPFS

OPFS is useful for high-performance origin-private files, especially from workers. Use it for caches, generated indexes, and file-backed engines. Use explicit export/import when the user needs to recover or move data.

Gotcha: Origin-private means app-private, not user-owned. If the user cannot get their data out, it is not a complete local-first story.