IndexedDB: A Client-Side Database for Structured Data
What Is IndexedDB?
IndexedDB is a browser API that lets you store large amounts of structured data locally. As a client-side database, it handles indexed queries, transactions, and version upgrades, making it far more capable than localStorage. Unlike localStorage, which only handles strings, IndexedDB can store any serializable JavaScript object. It is asynchronous, uses a transactional model, and lets you create indexes for fast queries.
This guide covers the core concepts you need to build real applications with the IndexedDB client-side storage API.
Opening a Database
The first step is to call indexedDB.open(). This method takes a name and a version number, and returns an IDBRequest object, not the database itself. The database only becomes available when the onsuccess event fires.
const request = indexedDB.open('NotesDB', 1);
request.onerror = () => {
console.error('Failed to open database:', request.error);
};
request.onsuccess = (event) => {
const db = event.target.result;
console.log('Database opened:', db.name, db.version);
// db is an IDBDatabase
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
console.log('Database needs upgrading — schema changes go here');
};
onupgradeneeded fires when the database does not exist or when the version you passed is higher than the stored version. This is the only place where you can create or delete object stores and indexes.
Creating object stores
An object store is like a table. You create it inside onupgradeneeded using db.createObjectStore(). The second argument is an options object where you specify the key path and whether keys should auto-increment.
request.onupgradeneeded = (event) => {
const db = event.target.result;
// keyPath: 'id' means the 'id' property of stored objects is the key
const store = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true });
// Create an index on the 'createdAt' property
store.createIndex('createdAt', 'createdAt', { unique: false });
};
If autoIncrement is true, you omit the key when adding records and IndexedDB generates one for you. If false (or omitted), you must provide a key when adding data.
Adding and reading data
All data operations happen inside a transaction. You get a transaction with db.transaction(storeName, mode), where mode is 'readonly' or 'readwrite'.
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
// add() inserts; throws if a record with this key already exists
const addReq = store.add({ title: 'Hello', body: 'World', createdAt: Date.now() });
addReq.onsuccess = () => {
console.log('Record added with key:', addReq.result); // value: 1
};
addReq.onerror = () => {
console.error('Add failed:', addReq.error);
};
tx.oncomplete = () => {
console.log('Transaction committed');
db.close();
};
};
Use put() instead of add() if you want upsert behavior. put() inserts or updates, while add() throws on duplicate keys. This distinction matters when you expect data from outside sources that may arrive multiple times — use put() for idempotent writes and add() when duplicates indicate a real problem.
Reading data is straightforward with get(). Pass the key to retrieve a single record, or pass undefined/null-ish values to get nothing back. The request fires the onsuccess handler even when no record matches, so always check the result.
const tx = db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const getReq = store.get(1);
getReq.onsuccess = () => {
console.log('Record:', getReq.result);
// value: { id: 1, title: 'Hello', body: 'World', createdAt: ... }
};
getReq.onerror = () => {
console.error('Read failed:', getReq.error);
};
get() returns undefined if no record matches. It does not throw an error.
To retrieve all records, use getAll(). Unlike get(), which returns a single record, getAll() collects every matching entry into an array. You can pass an optional key range to limit the result set before it reaches your JavaScript code.
const getAllReq = store.getAll();
getAllReq.onsuccess = () => {
console.log('All records:', getAllReq.result);
// value: [{ id: 1, title: 'Hello', ... }]
};
Using Indexes
Indexes let you query records by any property, not just the key. You define them during store creation with createIndex(), then look them up with store.index().
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true });
store.createIndex('title', 'title', { unique: false });
};
Once the store has an index, you can query it. Call store.index('name') to get an IDBIndex, then use get() or getAll() the same way you would on a store. The index routes queries through the indexed property rather than the primary key.
const tx = db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const index = store.index('title');
const getReq = index.get('Hello');
getReq.onsuccess = () => {
console.log('Found:', getReq.result);
// value: { id: 1, title: 'Hello', body: 'World', createdAt: ... }
};
Key ranges with IDBKeyRange
To query a range of keys, use IDBKeyRange. It has static methods for different bounds:
// All keys from 1 to 100 (inclusive)
const range = IDBKeyRange.bound(1, 100);
// Keys less than 5
const range = IDBKeyRange.upperBound(5, true);
// Keys greater than or equal to 10
const range = IDBKeyRange.lowerBound(10);
// Keys equal to exactly 'foo'
const range = IDBKeyRange.only('foo');
Use a key range with getAll() or an index. Key ranges let IndexedDB filter records before they reach your JavaScript, which keeps result sets small and avoids pulling every record into memory just to discard most of them.
const index = store.index('createdAt');
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const range = IDBKeyRange.lowerBound(oneWeekAgo);
const req = index.getAll(range);
req.onsuccess = () => {
console.log('Notes from last week:', req.result);
};
Cursors for iterating records
Cursors let you step through records one at a time. You open one with openCursor() on a store or index. The onsuccess handler receives the cursor or null when there are no more records.
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const cursorReq = store.openCursor();
cursorReq.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
console.log('Iteration done');
return;
}
const record = cursor.value;
console.log('Key:', cursor.key, 'Value:', record);
// Update the current record
if (record.title === 'Hello') {
cursor.update({ ...record, title: 'Updated Hello' });
}
// Delete the current record
// cursor.delete();
// Move to the next record
cursor.continue();
};
The cursor’s continue() method moves to the next entry. You can also use continue(primaryKey) to jump to a specific key, or advance(count) to skip multiple records. Cursors are the most flexible way to iterate because you can update, delete, or skip records mid-iteration based on their values.
Updating and deleting
Beyond cursor-based updates, you can also modify records directly through the store using put(), delete(), and clear(). These are simpler when you already know the key and don’t need to examine each record first.
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
// Update: put() replaces the record at this key
const putReq = store.put({ id: 1, title: 'New Title', body: 'New body', createdAt: Date.now() });
putReq.onsuccess = () => console.log('Updated:', putReq.result);
// Delete: removes the record at this key
const deleteReq = store.delete(1);
deleteReq.onsuccess = () => console.log('Deleted');
// Clear: removes all records in the store
const clearReq = store.clear();
clearReq.onsuccess = () => console.log('Store cleared');
Transactions in detail
A transaction groups one or more operations so they succeed or fail together. When you pass an array of store names to transaction(), you can access multiple stores in one transaction. This is useful when a write operation needs to update records in two stores atomically — if either fails, neither commit is visible.
const tx = db.transaction(['notes', 'tags'], 'readwrite');
const notesStore = tx.objectStore('notes');
const tagsStore = tx.objectStore('tags');
Transactions emit oncomplete, onerror, and onabort. Use oncomplete to know the transaction finished successfully, onerror to catch failures, and onabort for manual rollbacks.
tx.oncomplete = () => console.log('Transaction complete');
tx.onerror = (event) => console.error('Transaction error:', event.target.error);
tx.onabort = () => console.log('Transaction aborted');
IndexedDB auto-commits a transaction when all pending requests have finished. Call tx.abort() manually if you need to roll back changes before the transaction finishes.
Common mistakes to avoid
A few things trip people up regularly:
open()is async. The database is not available right after the call. You must wait foronsuccess.onupgradeneededis the only place for schema changes. CallingcreateObjectStore()anywhere else throws an error.- Version must be an integer. Using
1.5or2.1causes failures. Always useMath.floor()or whole numbers. - Transactions are not blocking. They auto-commit when requests settle. You cannot pause a transaction to wait for async code.
add()vsput(): add throws on duplicate keys, put overwrites them. Useput()for upserts.
Designing a store layout
Good databases start with a simple store layout. Keep one object store focused on one record shape, then add indexes for the fields you actually query. If a future screen needs a new access pattern, add an index during a version upgrade instead of working around the current schema. That keeps reads fast and migrations predictable, even when the app grows.
Think in Transactions
Every write should belong to a transaction that does one job. Group the changes that must succeed together, and keep async work outside the transaction boundary so it does not stay open longer than necessary. In IndexedDB, short transactions are easier to reason about and less likely to collide with other tabs or tasks.
Design for Failure
Database code needs a recovery story. Handle onerror, check version upgrades carefully, and expect stale data if another tab changed the schema first. When a request fails, do not assume the whole app is broken; often the right answer is to retry, refresh the store, or rebuild a cache from scratch. Keeping the failure path simple makes the rest of the code safer.
Query like you mean it
Use indexes and key ranges to narrow the data before it reaches JavaScript. Pulling everything into memory and filtering afterward works for tiny stores, but it scales poorly. If you know you need recent records, a tagged subset, or a single record by key, let IndexedDB do that work first. Your UI will stay faster, and the code will read more directly.
Migrations as normal work
A version bump should be treated like a regular part of product work, not a rare emergency. New fields, indexes, and object stores all fit naturally inside onupgradeneeded. If you plan the migration path early, you can keep old data available while the new shape lands, which makes releases safer and easier to roll forward.
Read paths should stay narrow
Once records are in the store, keep read operations focused. Use a key, an index, or a bounded cursor instead of pulling everything out and filtering in JavaScript. That keeps the database doing the lookup work and leaves your app with smaller result sets. It also makes pagination and partial refreshes much simpler to reason about.
Keep upgrade logic local
The code that changes the schema should live near the schema itself. That makes version changes easier to test and easier to review because the migration story is visible in one place. When a new field or index arrives, the upgrade handler can set it up without forcing the rest of the app to know about the internal steps.
Think in records, not rows
IndexedDB is more comfortable when you treat each object as a complete record rather than as a set of table cells. That mindset helps you design payloads that are useful on their own, which in turn makes reads faster and updates simpler. If the app needs a different shape later, transform the record at the edge instead of storing a half-finished version.
See Also
- /reference/json: JSON serialization, which IndexedDB uses internally for storing objects
- /reference/async-apis: async patterns in JavaScript, useful for wrapping IndexedDB’s callback-based API
- /reference/map-and-set: other JavaScript data structures for storing key-value data