docs-src/docs/articles/indexeddb/indexeddb-tutorial.md
import {Steps} from '@site/src/components/steps';
IndexedDB is the standard browser storage API for storing larger amounts of structured data on the client, including files and blobs. It runs in every modern browser and keeps your data on disk, so it survives page reloads and works offline. This tutorial teaches you how to use IndexedDB from scratch with the raw API, then shows where the native API falls short, and how RxDB adds the missing database layer on top of it.
<RxdbLogo alt="IndexedDB tutorial" />We start with plain IndexedDB and no libraries, so you understand what the browser gives you on its own. If you already know the API and only want the parts that hurt, jump to The Limits of IndexedDB.
IndexedDB is a transactional database built into every browser. It stores JavaScript objects on disk, inside the user's browser, and lets you look them up by a primary key or by secondary indexes. It is a low-level building block, not a developer-facing database engine, so a minute on the core ideas pays off before you write any code.
In the early days of the web, the browser could only keep small strings in cookies. The first attempt at a real client-side database was Web SQL Database, a spec that put a full SQL engine (SQLite) into the browser. It was deprecated in 2010, because every browser would have had to ship the exact same SQLite build and there was no independent standard to implement against.
IndexedDB was the answer to that problem. Instead of SQL, it defines a storage engine that each browser can build on its own, and it became a W3C standard in 2015. The goals were clear:
The last goal is why raw IndexedDB feels so bare. It was designed as a foundation for libraries, not as the thing you use directly.
{ id: 'todo1', name: 'Learn IndexedDB', category: 'work', done: false } goes in and comes out as one whole record.id field (keyPath: 'id'), so every todo needs its own unique id.category equals work, without reading the whole store. You define indexes once, at the moment the store is created, and the browser keeps them up to date on every write.IndexedDB is older than Promises, so it reports results through callbacks. A callback is a function you hand to the API, and the API calls it back later, once the work is done. IndexedDB gives you two on every request: onsuccess when the operation worked and onerror when it failed. The result never comes back as a return value, it arrives inside the callback.
A Promise represents a value that is not ready yet, and you read it with await or .then(). The difference in practice:
// callback style: the result lives inside onsuccess
const request = store.get('todo1');
request.onsuccess = () => {
console.log(request.result);
};
// promise style: the result is the return value
const todo = await getTodo('todo1');
console.log(todo);
Callbacks are harder to work with for a few concrete reasons:
await each step in order.onerror, instead of one try/catch around the whole flow.Keep this in mind while reading the tutorial below. Every onsuccess you see is a place where a Promise-based database would let you await the result instead.
The example below builds a small todos store, adds an index, and runs the full create, read, update, and delete cycle. Every record has the shape { id, name, category, done }. You can paste each block into the browser console and follow along.
You open a database by name and version. The onupgradeneeded event fires only on the first open or when you raise the version number, and it is the only place where you are allowed to create object stores and indexes.
const request = indexedDB.open('todos-db', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// create the store on first open, keyed by the 'id' field
const store = db.createObjectStore('todos', { keyPath: 'id' });
// add a secondary index so we can query by category later
store.createIndex('category', 'category', { unique: false });
};
let db;
request.onsuccess = (event) => {
db = event.target.result; // the database is ready to use here
};
request.onerror = (event) => {
console.error('Could not open the database', event.target.error);
};
Every write needs a readwrite transaction. You open the transaction, get the object store, and call add. The transaction commits on its own once all its requests are done.
function addTodo(todo) {
const tx = db.transaction('todos', 'readwrite');
const store = tx.objectStore('todos');
const request = store.add(todo);
request.onsuccess = () => console.log('added key', request.result);
tx.onerror = () => console.error('write failed', tx.error);
}
addTodo({ id: 'todo1', name: 'Learn IndexedDB', category: 'work', done: false });
A readonly transaction is enough for reads. store.get(key) returns a request, and the result arrives on its onsuccess event, never as a return value.
const tx = db.transaction('todos', 'readonly');
const store = tx.objectStore('todos');
const request = store.get('todo1');
request.onsuccess = () => {
console.log(request.result);
// > { id: 'todo1', name: 'Learn IndexedDB', category: 'work', done: false }
};
To fetch many records by a field, you go through the index you created. IDBKeyRange.only('work') limits the result to records where category equals work.
const tx = db.transaction('todos', 'readonly');
const index = tx.objectStore('todos').index('category');
const request = index.getAll(IDBKeyRange.only('work'));
request.onsuccess = () => {
console.log(request.result); // all todos where category === 'work'
};
There is no partial update. You read the record, change it in memory, and write the whole object back with put, which overwrites the record that has the same key.
const tx = db.transaction('todos', 'readwrite');
const store = tx.objectStore('todos');
const getRequest = store.get('todo1');
getRequest.onsuccess = () => {
const todo = getRequest.result;
todo.done = true;
store.put(todo);
};
Deleting is a single call inside a readwrite transaction.
const tx = db.transaction('todos', 'readwrite');
tx.objectStore('todos').delete('todo1');
That is the whole raw API. Notice that nothing here returns a Promise, so you cannot await a read or write, and you have to keep every transaction alive inside its own callback chain.
The tutorial above works, but as soon as your app grows past a demo, the native API starts to bite back. These are the limits you will run into.
await a request, and a transaction auto-commits as soon as control returns to the event loop, so it cannot survive an await in the middle. Real code turns into nested onsuccess handlers or a pile of manual Promise wrappers.done: true/false field cannot be indexed directly.None of this makes IndexedDB a bad storage engine. It is a good place to keep data on disk. The trouble starts when you expect it to behave like a database, because it does not. That is the gap RxDB fills.
RxDB (Reactive Database) is a local-first, NoSQL database for JavaScript applications. It runs on top of IndexedDB (and other storages) and gives you the database features the raw API is missing, while your data still lives in IndexedDB under the hood. Switching storages is a configuration change, not a rewrite.
In the browser you have two common ways to store into IndexedDB with RxDB: the free Dexie.js storage, which wraps IndexedDB and is a good default, or the IndexedDB RxStorage with 👑 premium access, which is faster and ships a smaller build. The examples below use the Dexie storage.
RxDB gives you a clean async API and validates every write against a JSON schema, so bad data never reaches disk. Compare this to the callback code from the tutorial above.
import { createRxDatabase } from 'rxdb/plugins/core';
import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';
const db = await createRxDatabase({
name: 'todos-db',
storage: getRxStorageDexie() // stores into IndexedDB under the hood
});
await db.addCollections({
todos: {
schema: {
version: 0,
primaryKey: 'id',
type: 'object',
properties: {
// the primary key must have a maxLength
id: { type: 'string', maxLength: 100 },
name: { type: 'string' },
category: { type: 'string' },
done: { type: 'boolean' }
},
required: ['id', 'name', 'category', 'done']
}
}
});
// a single awaitable line instead of an onupgradeneeded/onsuccess chain
const doc = await db.todos.insert({
id: 'todo1',
name: 'Learn RxDB',
category: 'work',
done: false
});
Every RxDB query is observable. You subscribe once, and it emits a new result whenever a matching document changes, even when the change happens in another part of your app or another browser tab. This is the reactivity the native API leaves you to build yourself.
const observable = db.todos.find({
selector: { done: { $eq: false } }
}).$; // get the observable via RxQuery.$
observable.subscribe(openTodos => {
console.log('Currently have ' + openTodos.length + ' things to do');
// -> re-render your UI here with the updated list
});
Instead of opening a cursor and filtering by hand, you write MongoDB-style (Mango) queries that combine filter, sort, skip, and limit in one object. And a boolean field like done is queryable, which a raw IndexedDB index cannot do.
const results = await db.todos.find({
selector: {
done: { $eq: false },
category: { $eq: 'work' }
},
sort: [{ name: 'asc' }],
skip: 0,
limit: 10
}).exec();
RxDB ships a Sync Engine that replicates your IndexedDB data to a backend over HTTP, WebSocket, or GraphQL, with conflict handling and offline queueing built in. Writes made offline sync later, and changes flow back to every connected client in realtime. Raw IndexedDB gives you none of this. See IndexedDB sync for the full picture.
RxDB uses batched cursors and other techniques to work around the slow parts of IndexedDB, and it helps you handle quota and eviction instead of letting the browser surprise you. For the details, read Slow IndexedDB and IndexedDB max storage limit.
You open a database with indexedDB.open(name, version), create an object store inside the onupgradeneeded event, and then read and write records inside readwrite or readonly transactions. Every operation returns a request whose result arrives on an onsuccess callback. The step-by-step tutorial above walks through the full create, read, update, and delete cycle.
A record is one complete JavaScript object stored inside an object store under a key. It is not a single value like a localStorage string, and it is not a row of separate columns like in SQL. You put an object in and you get the same object back. See The Core Concepts above.
</details> <details> <summary>What is an index in IndexedDB?</summary>An index is a secondary lookup path. Without it you can only find a record by its primary key. An index lets you find records by another field, for example every todo where category equals work, without scanning the whole store. You create indexes once, inside onupgradeneeded, and the browser keeps them current on every write.
A callback is a function you pass to an API that gets called later with the result, like IndexedDB's onsuccess. A Promise is a value you read with await or .then(). Promises let you write straight-line code with one try/catch, while callbacks nest and need error handling on every step. This is a large part of why raw IndexedDB feels harder than a modern database.
Yes, for structured data. localStorage only stores strings, is synchronous, and is capped at a few megabytes. IndexedDB stores objects and blobs, works asynchronously, and holds much more data. For simple key-value flags, localStorage is fine.
</details> <details> <summary>Why is IndexedDB so hard to use?</summary>The API predates Promises and is built around events and callbacks, so control flow is verbose and a transaction cannot survive an await. It also has no reactivity, no schema, and only single-field range queries. Most teams add a wrapper. See the best IndexedDB wrapper for a comparison.
No. IndexedDB stores data in one browser, on one device, in one origin, and has no built-in sync. You have to build replication yourself or use a library like RxDB that ships a Sync Engine. See IndexedDB sync.
</details> <details> <summary>Does RxDB use IndexedDB?</summary>Yes. RxDB can store its data in IndexedDB through the free Dexie.js storage or the premium IndexedDB RxStorage. You keep IndexedDB as the storage and get a real database API, reactivity, and sync on top.
</details>