IndexedDB for large-scale client-side data
IndexedDB is a NoSQL storage system built into browsers that lets you persist large amounts of structured data. It supports transactions and typical database operations like search, get, and put. Each database is scoped to a single origin, so data cannot leak across domains. Storage limits are generous and vary by browser. This guide uses Jake Archibald's IndexedDB Promised library, which mirrors the native API but wraps it in promises for cleaner await-based syntax.
Core concepts
- Database: The top-level container holding object stores.
- Object store: A bucket for a specific type of data, similar to a table in a relational database. Unlike SQL tables, the JavaScript data types of values in a store do not need to be consistent.
- Index: A structure within an object store that organizes records by a specific property for faster retrieval.
- Transaction: A wrapper around one or more operations guaranteeing atomicity. If any action fails, none are applied, and the database state is unchanged.
- Cursor: A mechanism for iterating over multiple records.
Feature detection
IndexedDB is nearly universally supported, but a quick check is cheap in older environments. Verify the window object:
function indexedDBStuff () { // Check for IndexedDB support: if (!('indexedDB' in window)) { // Can't use IndexedDB console.log("This browser doesn't support IndexedDB"); return; } else { // Do IndexedDB stuff here: // ... } } // Run IndexedDB code: indexedDBStuff();
Opening and upgrading a database
Use openDB() from the idb library to open a database. If it does not exist, it is created automatically:
import {openDB} from 'idb'; async function useDB () { // Returns a promise, which makes `idb` usable with async-await. const dbPromise = await openDB('example-database', version, events); } useDB();
The method resolves with a database object. The second parameter is a version number, and the third is an events object used to configure the database. A practical example:
import {openDB} from 'idb'; async function useDB () { // Opens the first version of the 'test-db1' database. // If the database does not exist, it will be created. const dbPromise = await openDB('test-db1', 1); } useDB();
The support check at the top exits early if IndexedDB is unavailable. Object stores can only be created or removed during an upgrade() event, which runs when the database is first created or when its version number changes.
Creating object stores and keys
Design each database with one object store per persisted data type. Inside the upgrade() method of the events object, call createObjectStore():
import {openDB} from 'idb'; async function createStoreInDB () { const dbPromise = await openDB('example-database', 1, { upgrade (db) { // Creates an object store: db.createObjectStore('storeName', options); } }); } createStoreInDB();
The first argument is the store name; the second optional configuration object defines its properties. A concrete example:
import {openDB} from 'idb'; async function createStoreInDB () { const dbPromise = await openDB('test-db1', 1, { upgrade (db) { console.log('Creating a new object store...'); // Checks if the object store exists: if (!db.objectStoreNames.contains('people')) { // If the object store does not exist, create it: db.createObjectStore('people'); } } }); } createStoreInDB();
The browser throws if you create a store that already exists, so wrap the call in an existence check.
Each object store defines how records are uniquely identified, either with a key path (a property on the data that is always unique) or a key generator (an auto-incrementing value). A key path uses the keyPath option:
import {openDB} from 'idb'; async function createStoreInDB () { const dbPromise = await openDB('test-db2', 1, { upgrade (db) { if (!db.objectStoreNames.contains('people')) { db.createObjectStore('people', { keyPath: 'email' }); } } }); } createStoreInDB();
A key generator uses autoIncrement and can either store the key separately or assign it to a named property:
import {openDB} from 'idb'; async function createStoreInDB () { const dbPromise = await openDB('test-db2', 1, { upgrade (db) { if (!db.objectStoreNames.contains('notes')) { db.createObjectStore('notes', { autoIncrement: true }); } } }); } createStoreInDB();
import {openDB} from 'idb'; async function createStoreInDB () { const dbPromise = await openDB('test-db2', 1, { upgrade (db) { if (!db.objectStoreNames.contains('logs')) { db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true }); } } }); } createStoreInDB();
Choose a natural unique property as a key path when your data has one; otherwise let the store auto-generate a value. These three stores illustrate the options:
import {openDB} from 'idb'; async function createStoresInDB () { const dbPromise = await openDB('test-db2', 1, { upgrade (db) { if (!db.objectStoreNames.contains('people')) { db.createObjectStore('people', { keyPath: 'email' }); } if (!db.objectStoreNames.contains('notes')) { db.createObjectStore('notes', { autoIncrement: true }); } if (!db.objectStoreNames.contains('logs')) { db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true }); } } }); } createStoresInDB();
Indexes for targeted queries
Indexes let you fetch records by a property other than the primary key. They reside in the reference object store and use their own property as the key path, while retaining the same underlying data. Create them at store-creation time via createIndex():
import {openDB} from 'idb'; async function createIndexInStore() { const dbPromise = await openDB('storeName', 1, { upgrade (db) { const objectStore = db.createObjectStore('storeName'); objectStore.createIndex('indexName', 'property', options); } }); } createIndexInStore();
The first argument is the index name, the second is the data property to index, and the third option object accepts two flags: unique prevents duplicate keys, and multiEntry controls behavior when the indexed property is an array. If multiEntry is true, each array element gets its own index entry; otherwise the entire array is a single entry.
Example with two object stores and their indexes:
import {openDB} from 'idb'; async function createIndexesInStores () { const dbPromise = await openDB('test-db3', 1, { upgrade (db) { if (!db.objectStoreNames.contains('people')) { const peopleObjectStore = db.createObjectStore('people', { keyPath: 'email' }); peopleObjectStore.createIndex('gender', 'gender', { unique: false }); peopleObjectStore.createIndex('ssn', 'ssn', { unique: true }); } if (!db.objectStoreNames.contains('notes')) { const notesObjectStore = db.createObjectStore('notes', { autoIncrement: true }); notesObjectStore.createIndex('title', 'title', { unique: false }); } if (!db.objectStoreNames.contains('logs')) { const logsObjectStore = db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true }); } } }); } createIndexesInStores();
Assign the result of createObjectStore() to a variable before calling createIndex() on it.
CRUD operations with IDB
Creating, reading, updating, and deleting records in IndexedDB follows the same basic pattern: get the database object, open a transaction, open an object store on that transaction, then run the operation. Because the API is promise-based rather than event-driven, you can structure these steps with await or .then() instead of wiring up request event listeners.
Transactions act as a safety wrapper around one or more operations. If any action inside a transaction fails, the entire transaction rolls back. When you open a transaction, you specify the object stores it covers and whether it is read-only or read-write.
Adding records with add()
To create a new record, call add() with the object store name and the data object. The method returns a promise, but a resolved promise does not guarantee the write succeeded — the transaction must also complete. Always check transaction.done() for write operations to confirm changes actually landed.
For multiple operations, pass them all to Promise.all along with tx.done so you wait for both the individual operations and the transaction itself:
import {openDB} from 'idb';
async function addItemsToStore () {
const db = await openDB('test-db4', 1, {
upgrade (db) {
if (!db.objectStoreNames.contains('foods')) {
db.createObjectStore('foods', { keyPath: 'name' });
}
}
});
// Create a transaction on the 'foods' store in read/write mode:
const tx = db.transaction('foods', 'readwrite');
// Add multiple items to the 'foods' store in a single transaction:
await Promise.all([
tx.store.add({
name: 'Sandwich',
price: 4.99,
description: 'A very tasty sandwich!',
created: new Date().getTime(),
}),
tx.store.add({
name: 'Eggs',
price: 2.99,
description: 'Some nice eggs you can cook up!',
created: new Date().getTime(),
}),
tx.done
]);
}
addItemsToStore();
In this example, the transaction is opened in 'readwrite' mode on the 'foods' store. Each add() call returns a promise, and tx.done resolves when the transaction completes.
Reading records with get()
Fetching a single record is straightforward. Call get() on the store with the primary key value of the row you want. The method returns a promise you can await directly:
import {openDB} from 'idb';
async function getItemFromStore () {
const db = await openDB('test-db4', 1);
const value = await db.get('foods', 'Sandwich');
console.dir(value);
}
getItemFromStore();
Read operations do not require the extra transaction-completion check needed for writes; the get() promise alone gives you the resulting value.
Updating with put()
To update an existing record — or insert one if the key does not exist — use put() on the object store. As with other write operations, verifying tx.done inside a Promise.all is required to confirm the change was committed:
import {openDB} from 'idb';
async function updateItemsInStore () {
const db = await openDB('test-db4', 1);
// Create a transaction on the 'foods' store in read/write mode:
const tx = db.transaction('foods', 'readwrite');
// Update multiple items in the 'foods' store in a single transaction:
await Promise.all([
tx.store.put({
name: 'Sandwich',
price: 5.99,
description: 'A MORE tasty sandwich!',
updated: new Date().getTime() // This creates a new field
}),
tx.store.put({
name: 'Eggs',
price: 3.99,
description: 'Some even NICER eggs you can cook up!',
updated: new Date().getTime() // This creates a new field
}),
tx.done
]);
}
updateItemsInStore();
How updates match rows depends on the key strategy. With a keyPath, each row carries an inline key and you update rows by specifying that key. With an autoIncrement primary key, the key is out-of-line and generated by the database.
Removing records with delete()
The delete() method removes a row by its primary key. It follows the same transaction pattern and requires the transaction-completion check:
import {openDB} from 'idb';
async function deleteItemsFromStore () {
const db = await openDB('test-db4', 1);
// Create a transaction on the 'foods' store in read/write mode:
const tx = db.transaction('foods', 'readwrite');
// Delete multiple items from the 'foods' store in a single transaction:
await Promise.all([
tx.store.delete('Sandwich'),
tx.store.delete('Eggs'),
tx.done
]);
}
deleteItemsFromStore();
Fetching more than one record
The get() method retrieves objects individually. For larger reads, IndexedDB offers getAll() and cursors.
The getAll() method
Call getAll() directly on an object store or index to return every object it contains, ordered by the primary key. It is the fastest way to load a full store, but offers no filtering or paging:
import {openDB} from 'idb';
async function getAllItemsFromStore () {
const db = await openDB('test-db4', 1);
// Get all values from the designated object store:
const allValues = await db.getAll('foods');
console.dir(allValues);
}
getAllItemsFromStore();
Iterating with cursors
Cursors give you finer control by selecting each object in a store one at a time. Use openCursor() inside a transaction, then walk the records in a loop. At each position you can read the row's key and value properties, perform whatever app logic you need, then call continue() to advance to the next record. The loop ends when the cursor passes the last row:
import {openDB} from 'idb';
async function getAllItemsFromStoreWithCursor () {
const db = await openDB('test-db4', 1);
const tx = await db.transaction('foods', 'readonly');
// Open a cursor on the designated object store:
let cursor = await tx.store.openCursor();
// Iterate on the cursor, row by row:
while (cursor) {
// Show the data in the row at the current cursor position:
console.log(cursor.key, cursor.value);
// Advance the cursor to the next row:
cursor = await cursor.continue();
}
}
getAllItemsFromStoreWithCursor();
Targeted reads with ranges and indexes
Indexes let you query an object store by any property, not just the primary key. Create an index on the property of interest, define a key range on that property, and retrieve matching records with either getAll() or a cursor.
Define ranges with the IDBKeyRange object:
upperBound()— maximum valuelowerBound()— minimum valuebound()— both minimum and maximumonly()— a single valueincludes()— value-inclusion check
These bounds are inclusive by default. Pass true as an argument to make a bound exclusive: the second argument for lowerBound() or upperBound(), or the third and fourth arguments for bound() to exclude the lower and upper limits respectively.
The example below combines an index on the 'price' property with a cursor to find foods within a user-supplied price window:
import {openDB} from 'idb';
async function searchItems (lower, upper) {
if (!lower === '' && upper === '') {
return;
}
let range;
if (lower !== '' && upper !== '') {
range = IDBKeyRange.bound(lower, upper);
} else if (lower === '') {
range = IDBKeyRange.upperBound(upper);
} else {
range = IDBKeyRange.lowerBound(lower);
}
const db = await openDB('test-db4', 1);
const tx = await db.transaction('foods', 'readonly');
const index = tx.store.index('price');
// Open a cursor on the designated object store:
let cursor = await index.openCursor(range);
if (!cursor) {
return;
}
// Iterate on the cursor, row by row:
while (cursor) {
// Show the data in the row at the current cursor position:
console.log(cursor.key, cursor.value);
// Advance the cursor to the next row:
cursor = await cursor.continue();
}
}
// Get items priced between one and four dollars:
searchItems(1.00, 4.00);
The code opens the object store, then the 'price' index, and opens a cursor with the specified range. The promise from the cursor resolves to the first matching object, or undefined when the range is empty. Calling cursor.continue() steps through the remaining matches until the range is exhausted.
Upgrading an existing database
The database version number is passed as the second parameter to openDB(). When the version you supply is higher than the version of the database currently in the browser, the upgrade callback on the event object fires. That callback is your opportunity to add object stores and indexes.
Inside the upgrade callback, the db object exposes an oldVersion property that tells you which version the browser already has. A switch statement on that value lets you apply different migrations depending on the starting point:
import {openDB} from 'idb';
const db = await openDB('example-database', 2, {
upgrade (db, oldVersion) {
switch (oldVersion) {
case 0:
// Create first object store:
db.createObjectStore('store', { keyPath: 'name' });
case 1:
// Get the original object store, and create an index on it:
const tx = await db.transaction('store', 'readwrite');
tx.store.createIndex('name', 'name');
}
}
});
Here the target version is 2. On a browser where no such database exists yet, oldVersion is 0, so the switch enters case 0 and creates the 'store' object store.
Notice that this switch has no break statements between case blocks. That's deliberate: if the existing database is several versions behind—or missing entirely—execution falls through each case in order until the schema is fully current. In this example, after case 0 finishes, control flows into case 1, which adds the name index to store.
To add another index later, bump the version and append another case:
import {openDB} from 'idb';
const db = await openDB('example-database', 3, {
upgrade (db, oldVersion) {
switch (oldVersion) {
case 0:
// Create first object store:
db.createObjectStore('store', { keyPath: 'name' });
case 1:
// Get the original object store, and create an index on it:
const tx = await db.transaction('store', 'readwrite');
tx.store.createIndex('name', 'name');
case 2:
const tx = await db.transaction('store', 'readwrite');
tx.store.createIndex('description', 'description');
}
}
});
If the database from the prior example is still in the browser, opening it at version 3 sets oldVersion to 2. The switch skips case 0 and case 1 and runs case 2, which creates the description index. The browser then holds a version-3 database with a store object store carrying both name and description indexes.
Additional resources
For a deeper look at IndexedDB behavior and quota mechanics, the following references are useful:



