IndexedDBAdapter
Persistent browser storage — survives reloads and tab closures
IndexedDBAdapter uses the browser's native IndexedDB API for persistent
storage. Data survives page reloads, tab closures, and browser restarts. It is
the default adapter in browser environments.
How it works
- Each collection is an object store with
idas the key path - Schema-defined indexes are created as native IDB indexes on each store
- Plugin stores (
_ctrodb_sync_changes, etc.) are created withidkey path and can declare their own indexes viaPluginStoreConfig - A special
_ctrodb_metaobject store holds schema version, sync state, and other internal metadata - The database version is driven by
schema.version— bumping it triggers theonupgradeneededmigration handler
Explicit instantiation
import { Database, createAdapter } from "ctrodb"
const db = new Database({
adapter: "indexeddb",
// or: adapter: new IndexedDBAdapter(),
// or: adapter: createAdapter("indexeddb"),
schema: {
version: 1,
collections: {
todos: {
fields: { title: { type: "string" } },
},
},
},
})
await db.connect()
Schema migration
When you bump schema.version, IndexedDB fires onupgradeneeded. The adapter
creates new object stores and indexes for any that don't already exist.
Existing data is preserved.
const db = new Database({
schema: {
version: 2, // bump to trigger migration
collections: {
todos: {
fields: { title: { type: "string" }, completed: { type: "boolean" } },
indexes: [{ field: "title" }],
},
},
},
})
What happens during migration
For each collection in schema.collections:
- If the object store doesn't exist, it is created with
{ keyPath: "id" } - Schema-defined indexes are created:
store.createIndex(field, field, { unique })
For plugin stores (schema.pluginStoreNames):
- If the store doesn't exist, it is created with
{ keyPath: "id" } - If the entry is a
PluginStoreConfigwithindexes, those indexes are created
The _ctrodb_meta store is created if it doesn't exist.
Existing stores and indexes are never modified or deleted — IndexedDB does not support altering existing indexes. To change an index, you must use a new store name or delete the database.
scanIndex — native IDB indexes
Unlike the MemoryAdapter (which scans in JavaScript), the IndexedDBAdapter uses
native IDB indexes for scanIndex():
// Internal implementation (simplified)
function idbScanIndex(db, collection, indexName, range, postFilters) {
const tx = db.transaction(collection, "readonly")
const store = tx.objectStore(collection)
const index = store.index(indexName)
const request = range ? index.getAll(range) : index.getAll()
// ... apply postFilters, resolve
}
This means:
- Only records matching the
IDBKeyRangeare loaded from disk — not the full collection - The native B-tree index makes lookups O(log n) instead of O(n)
- Post-filter conditions are still applied in JavaScript (for compound conditions that can't be expressed as a single IDBKeyRange)
IDBKeyRange usage
The query planner creates IDBKeyRange objects for index-scan queries:
IDBKeyRange.only(value) // field == value
IDBKeyRange.lowerBound(value) // field >= value
IDBKeyRange.lowerBound(value, true) // field > value
IDBKeyRange.upperBound(value) // field <= value
IDBKeyRange.upperBound(value, true) // field < value
IDBKeyRange.bound(lower, upper) // lower <= field <= upper
IDBKeyRange.bound(lower, upper, lOpen, uOpen) // inclusive/exclusive bounds
Post-filter conditions
After the index scan, post-filter conditions are applied in JavaScript.
These support the full query operator set (==, !=, >, >=, <, <=)
but skip search type conditions (handled by the FTS plugin).
Transaction behavior
Transactions use IDBTransaction with readwrite mode spanning all
object stores. This is a single-transaction scope — all reads and writes
within the callback share the same transaction.
await db.transaction(async (ctx) => {
const todos = ctx.collection("todos")
const users = ctx.collection("users")
await todos.create({ id: "1", title: "A" })
await users.create({ id: "1", name: "Alice" })
// Both writes are in the same IDBTransaction
})
The transaction context returns a bound collection proxy — you call methods without passing the collection name:
interface BoundCollection {
create(data: Record<string, unknown>): Promise<Record<string, unknown>>
findById(id: ID): Promise<Record<string, unknown> | undefined>
findAll(): Promise<Record<string, unknown>[]>
update(id: ID, changes: Record<string, unknown>): Promise<Record<string, unknown>>
delete(id: ID): Promise<void>
}
const todos = ctx.collection("todos")
// ^— BoundCollection (not the raw adapter)
await todos.create({ id: "1", title: "A" })
This differs from the MemoryAdapter, where ctx.collection() returns the
raw adapter and you must pass the collection name to each method.
Rollback
If the transaction callback throws, tx.abort() is called — IndexedDB
automatically discards all writes in the transaction scope.
await db.transaction(async (ctx) => {
await ctx.collection("todos").create({ id: "1", title: "A" })
throw new Error("rollback")
})
// Record "1" is NOT created
Metadata store
Metadata is stored in the _ctrodb_meta object store:
// Each metadata entry is a record: { id: key, key, value }
await adapter.setMetadata("schemaVersion", 2)
const version = await adapter.getMetadata("schemaVersion") // 2
This store is used internally for schema version tracking and by the sync
engine for cursor state (sync:lastPullCursor, sync:lastSyncAt).
Plugin store indexes
Plugins that declare storeNames with PluginStoreConfig get their indexes
created during migration:
// Sync plugin declares:
storeNames: [{
name: "_ctrodb_sync_changes",
indexes: [
{ field: "status" },
{ field: "timestamp" },
],
}]
These indexes are created during onupgradeneeded when the store is first
created, exactly like schema-defined collection indexes.
deleteMany
deleteMany() uses a single readwrite transaction and iterates through the
provided IDs — each delete is an individual IDBRequest within the same
transaction scope:
async deleteMany(collection: string, ids: ID[]): Promise<void> {
const tx = db.transaction(collection, "readwrite")
const store = tx.objectStore(collection)
for (const id of ids) {
store.delete(id)
}
// resolves when the transaction completes
}
Limitations
- Not available in Node.js without
fake-indexeddbpolyfill - Size limits vary by browser (typically 50 MB to unlimited for most modern browsers). Chrome asks the user for permission above a threshold.
- Blocked on version change — if another tab has the same database open
with an older version, the
onupgradeneededevent fires but the upgrade is blocked until all other tabs close. The adapter logs a warning when this happens. - No support for modifying existing indexes — to change an index, you must create a new store or delete and recreate the database.
How is this guide?
Last updated on Jul 2, 2026