IndexedDB: Client-Side Databases
What Is IndexedDB?
IndexedDB is a browser API that lets you store large amounts of structured data locally. 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 IndexedDB.
Opening a Database
The first step is to call indexedDB.open(). This method takes a name and a version number, and returns an IDBRequest — 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.
Reading data is straightforward with get():
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():
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:
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:
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.
Updating and Deleting
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:
const tx = db.transaction(['notes', 'tags'], 'readwrite');
const notesStore = tx.objectStore('notes');
const tagsStore = tx.objectStore('tags');
Transactions emit oncomplete, onerror, and onabort:
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.
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