Reactive local data that survives a reload.
Build notes, drafts, reading lists, and offline workspaces with TanStack DB's live queries and optimistic mutations. IndexedDB Collections save your rows between visits and share persisted changes with other active tabs and dedicated workers.
For example, a reading app can save articles and reading progress locally. A live query can show unread articles, while another joins articles with tags. Marking an article as read updates local query results immediately, persists the edit, and notifies other active tabs.
Choose IndexedDB Collections when the browser owns the data and your app needs reactive queries over it. This works for a standalone local app or for local data alongside server-synced Collections.
| Your data needs | Choose |
|---|---|
| Temporary UI state that lasts only for the current session | LocalOnly Collection |
| Small preferences stored through the Web Storage API | LocalStorage Collection |
| Local records with asynchronous persistence, structured values, and live queries | IndexedDB Collection |
| Persistence around an existing server sync adapter | SQLite Persistence |
IndexedDB Collections load their entire object store into memory. Choose a dataset that fits your app's memory budget. Local data access works offline once the app is loaded. Caching the app for offline startup and synchronizing data across devices require separate setup. Browser storage remains subject to quota, eviction, and user deletion.
Install the Collection package alongside your framework package or the core package:
npm install @tanstack/indexeddb-db-collection @tanstack/dbThe examples use @tanstack/db. Framework packages such as @tanstack/react-db also export createCollection and createTransaction.
Open one database with the stores your app needs, then share that database instance between Collections. Each Collection uses one store.
import { createCollection, createTransaction } from '@tanstack/db'
import {
createIndexedDB,
indexedDBCollectionOptions,
} from '@tanstack/indexeddb-db-collection'
type Todo = {
id: string
text: string
completed: boolean
}
const db = await createIndexedDB({
name: 'my-app',
version: 1,
stores: ['todos'],
})
const todos = createCollection(
indexedDBCollectionOptions<Todo>({
db,
name: 'todos',
getKey: (todo) => todo.id,
}),
)
await todos.preload()Create the database in a browser page or dedicated worker where IndexedDB is available. preload() restores the store's rows and resolves when the Collection is ready. A failed initial read rejects preload() and sets the Collection status to error.
Call insert, update, and delete directly. Mutation handlers are optional. The Collection applies optimistic changes and persists accepted mutations to IndexedDB.
const id = crypto.randomUUID()
const insert = todos.insert({
id,
text: 'Write a draft',
completed: false,
})
await insert.isPersisted.promise
const update = todos.update(id, (draft) => {
draft.completed = true
})
await update.isPersisted.promise
const remove = todos.delete(id)
await remove.isPersisted.promiseAwait isPersisted.promise to observe persistence success or failure. The returned transaction itself is not a Promise.
If you provide onInsert, onUpdate, or onDelete, the handler runs before persistence. A rejected handler leaves durable rows unchanged and rolls back its optimistic changes. Each accepted Collection batch writes rows and metadata in one IndexedDB transaction. A failed write aborts the batch.
Use these Collections with TanStack DB's query API. This query keeps an alphabetized list of unfinished todos:
import { createLiveQueryCollection, eq } from '@tanstack/db'
const unfinishedTodos = createLiveQueryCollection((q) =>
q
.from({ todo: todos })
.where(({ todo }) => eq(todo.completed, false))
.orderBy(({ todo }) => todo.text, 'asc')
.select(({ todo }) => ({ id: todo.id, text: todo.text })),
)
await unfinishedTodos.preload()
console.log(unfinishedTodos.toArray)An insert adds a matching todo to the result. Marking it complete removes it. Received changes from another active tab update the same query. TanStack DB's framework bindings can render these results in your UI.
The query runs over Collection rows in memory. IndexedDB handles persistence, while TanStack DB maintains the query result. You can also join these rows with other Collections, including Collections supplied by a server sync adapter. See Live Queries for joins, aggregates, and framework examples.
createIndexedDB accepts:
| Option | Required | Description |
|---|---|---|
| name | Yes | IndexedDB database name. |
| version | Yes | Database version. Increase it when adding stores. |
| stores | Yes | Store names to create during a version upgrade. |
| idbFactory | No | Custom IDBFactory, such as a factory from fake-indexeddb for tests. |
Store creation is additive. Omitting an existing store from stores preserves that store and its data. The _versions store is reserved for adapter metadata.
indexedDBCollectionOptions accepts:
| Option | Required | Description |
|---|---|---|
| db | Yes | Instance returned by createIndexedDB. |
| name | Yes | An existing object store with out-of-line keys (keyPath: null). |
| getKey | Yes | Extracts a stable string or number key from each row. |
| id | No | Collection identifier. Defaults to indexeddb-collection:<database>:<store>. |
| schema | No | A Standard Schema compatible schema for mutation and import validation. |
| onInsert, onUpdate, onDelete | No | Application handlers that must succeed before the adapter persists mutations. |
Collection stores must use out-of-line keys (keyPath: null) because persistence passes the value returned by getKey as an explicit IndexedDB key. createIndexedDB creates stores this way. If you reuse an existing store, check its key mode first. The inline-key examples in the lower-level createObjectStore reference are not compatible with Collection persistence.
Use values supported by IndexedDB's structured clone algorithm. Functions cannot be stored. Choose a consistent key type for each Collection.
A schema supplies the row type and can apply defaults or transformations. This example uses Zod, which requires the zod package:
import { z } from 'zod'
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean().default(false),
})
const validatedTodos = createCollection(
indexedDBCollectionOptions({
db,
name: 'todos',
schema: todoSchema,
getKey: (todo) => todo.id,
}),
)
await validatedTodos.utils.importData([{ id: 'draft', text: 'Write a draft' }])This example replaces the store with one row whose completed value is false. Use the schema configuration instead of the explicit Todo type parameter. See Schemas for more details.
For a manual transaction, call utils.acceptMutations from its mutation function. The utility persists only mutations owned by the receiving Collection.
const manual = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await todos.utils.acceptMutations(transaction)
},
})
manual.mutate(() => {
todos.insert({
id: crypto.randomUUID(),
text: 'Save this draft together with other edits',
completed: false,
})
})
await manual.commit()For a transaction involving several Collections, call each Collection's acceptance utility. Separate acceptance calls do not form one atomic IndexedDB transaction across Collections.
Automatic writes in one Collection persist in mutation order, even when their handlers finish out of order. Later writes wait for earlier handlers and persistence to settle before reporting isPersisted. Rejection contributes no durable write and allows the next mutation to proceed. Do not await a later automatic write from an earlier handler in the same Collection: each would wait for the other. This rule does not order separate Collections, manual acceptance, imports, or clears; explicitly order those operations when your application requires it.
The Collection exposes these methods through collection.utils:
| Method | Behavior |
|---|---|
| exportData() | Returns the durable rows as an array. |
| importData(rows) | Validates inputs and atomically replaces this store. Rejects duplicate keys. Failed validation or persistence preserves the previous rows. |
| clearObjectStore() | Removes this store's durable rows and publishes an empty source snapshot. Other stores remain intact. |
| getDatabaseInfo() | Returns the database name, version, and actual object store names. estimatedSize, when available, is origin-wide storage usage, including other databases and caches. |
| acceptMutations(transaction) | Persists the receiving Collection's mutations from a manual transaction. |
For a Collection whose schema output is also valid schema input, export and restore a snapshot:
const backup = await todos.utils.exportData()
await todos.utils.importData(backup)exportData() returns stored schema output; importData() accepts and validates schema input. A transforming schema may require an explicit conversion before import. For example, a string-to-Date transform exports Date values, which must be converted back to input strings. Arbitrary transforms have no general inverse; importData() is not an unchecked stored-output restore API.
To delete the entire database, use the exported administrative function:
import { deleteDatabase } from '@tanstack/indexeddb-db-collection'
await deleteDatabase('my-app')This operation targets the database name at its turn in the native request queue. It is not bound to a previously opened descriptor's database lifetime. Its success receipt does not publish Collection rows or send row notifications. Retained Collections stay errored with their snapshots; fresh Collections restore the resulting storage. Use clearObjectStore() to publish a live replacement for one store while the connection remains open.
importData copies the validated rows before it waits for storage. Later changes to caller-owned objects do not alter the imported snapshot. Updates follow the core callback-return snapshot rules for supported values, including native buffers and views. Treat public Collection rows as immutable.
Pages and dedicated workers on the same origin use the same database and store names to access the same data. After persistence, the adapter sends a BroadcastChannel notification. Active receiving Collections read the changes from IndexedDB and update their public snapshots.
If BroadcastChannel is unavailable, local persistence still works, but active tabs do not receive change notifications. This adapter does not synchronize data to a server or define a conflict-resolution policy for simultaneous writes to the same key from different Collections or tabs.
The app owns the shared database connection. Collection cleanup releases that Collection's sync resources. Close the database after every Collection that uses it finishes cleanup:
await todos.cleanup()
db.close()Connections created by createIndexedDB close automatically on native versionchange. Unexpected native connection closure also notifies their managed Collections. In both cases, active Collections enter error. The descriptor's db.close() method has the same local effect. Their last published rows remain available. Recreate affected Collections with a new instance using the current database version. Calling the raw db.db.close() bypasses managed Collection notification.
Closure prevents new native transactions. Already admitted writes can commit or abort; their callers receive that actual outcome. Committed writes finish their Collection confirmations, including clear/import replacements. Sync transactions accepted before closure still publish when their optimistic transactions settle. These publications keep the Collection in error; late startup and notification reads cannot publish or make it ready again. There is no automatic restart.
Connections opened outside createIndexedDB can still block upgrades or deletion. Their owner must close them. The operation's promise remains pending until the native request succeeds or fails.
Use onBlocked to report a native blocker without changing the request outcome:
const db = await createIndexedDB({
name: 'my-app',
version: 2,
stores: ['todos'],
onBlocked: (event) => {
console.info('Close older connections', event.oldVersion, event.newVersion)
},
})
await deleteDatabase('my-app', undefined, (event) => {
console.info('Deletion waits for open connections', event.oldVersion)
})The low-level openDatabase function accepts the same callback as its fifth argument. Native deletion reports newVersion: null. The callback does not cancel the operation or settle its promise. Dedicated workers receive the same persistence and connection notifications while running. This does not promise service-worker background delivery or recovery after worker termination.
These operations have no deadline or cancellation option. A blocked operation does not switch the Collection to in-memory storage.