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
| Failure | What happens | Gate |
|---|---|---|
| quota exceeded | write fails with quota error | show storage pressure and export path |
| eviction | best-effort data can disappear under pressure | request persistence for critical offline data |
| blocked upgrade | another tab holds old DB version | prompt user to close/reload other tabs |
| private mode cleanup | data may clear at session end | warn before treating it as durable |
| long main-thread export | UI freezes | run 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.