Skip to main content

How to Use IndexedDB for Client-Side Storage in JavaScript

Introduction

When localStorage and sessionStorage are not enough, you need IndexedDB. While Web Storage gives you a simple key-value store limited to around 5-10 MB of string data, real applications often need to store much larger amounts of structured data on the client: offline-capable apps, cached API responses, file uploads queued for later, or full datasets for client-side filtering and search.

IndexedDB is a low-level, transactional, NoSQL database built directly into the browser. It can store virtually any type of JavaScript value (objects, arrays, files, blobs, and even binary data), supports indexes for fast lookups, and can handle hundreds of megabytes of data. It powers the offline capabilities of Progressive Web Apps (PWAs), is used by frameworks like Firebase and PouchDB under the hood, and is the only serious option for complex client-side data persistence.

The trade-off? IndexedDB has a notoriously verbose, callback-based, event-driven API that can feel overwhelming at first. In this guide, you will learn the API step by step, from opening a database and creating stores to performing CRUD operations, searching with cursors and indexes, handling errors properly, and finally simplifying everything with a promise-based wrapper library.

What Is IndexedDB? (Transactional Object Store)

IndexedDB is a transactional object-oriented database that lives in the browser. Unlike relational databases (MySQL, PostgreSQL) that use tables, rows, and SQL, IndexedDB stores JavaScript objects directly and retrieves them by keys or indexed properties.

Key Characteristics

  • Key-value storage: Every record is a JavaScript value stored under a key. The value can be almost anything: objects, arrays, strings, numbers, Date objects, Blob, File, ArrayBuffer, and more.
  • Transactional: Every read and write operation happens inside a transaction. If any operation in a transaction fails, the entire transaction is rolled back, keeping data consistent.
  • Asynchronous: All operations are non-blocking. The API uses events (onsuccess, onerror) rather than returning results synchronously, so it never freezes the UI.
  • Same-origin: Each origin (protocol + domain + port) has its own set of databases. Scripts from other origins cannot access your data.
  • Large storage capacity: Browsers typically allow IndexedDB to use up to 50% of the available disk space (or more, depending on the browser). This is orders of magnitude more than localStorage.
  • Indexed: You can create indexes on object properties for fast lookups without scanning every record.
  • No SQL: There is no query language. You retrieve data by key, by index, or by iterating with cursors.

How IndexedDB Is Structured

Origin (e.g., https://myapp.com)
└── Database: "MyAppDB" (version 1)
├── Object Store: "users"
│ ├── Record: { id: 1, name: "Alice", email: "alice@example.com" }
│ ├── Record: { id: 2, name: "Bob", email: "bob@example.com" }
│ └── Index: "by_email" → fast lookup on "email" property
├── Object Store: "products"
│ ├── Record: { sku: "A100", name: "Widget", price: 9.99 }
│ └── Record: { sku: "B200", name: "Gadget", price: 19.99 }
└── Object Store: "orders"
└── ...

A single origin can have multiple databases. Each database contains object stores (similar to tables). Each object store holds records (key-value pairs). Object stores can have indexes for faster searching.

info

IndexedDB is supported in all modern browsers: Chrome, Firefox, Safari, Edge, and even in Web Workers and Service Workers. It is the standard for complex client-side storage.

Opening a Database, Versioning, and upgradeneeded

Before doing anything with IndexedDB, you must open a database. This is where the API starts to show its event-driven nature.

Opening a Database

Use indexedDB.open(name, version) to open (or create) a database:

const request = indexedDB.open('MyAppDB', 1);

request.onsuccess = (event) => {
const db = event.target.result;
console.log('Database opened successfully');
console.log('Database name:', db.name); // "MyAppDB"
console.log('Version:', db.version); // 1
console.log('Object stores:', db.objectStoreNames); // DOMStringList
};

request.onerror = (event) => {
console.error('Failed to open database:', event.target.error);
};

The open() call returns an IDBOpenDBRequest object. It does not return the database directly. You must listen for the onsuccess event to get the actual database connection (IDBDatabase).

The Version System

IndexedDB uses integer versioning to manage the database schema (its structure: which object stores exist, which indexes they have). The version number must be a positive integer (1, 2, 3, ...).

  • If the database does not exist, version 1 creates it.
  • If the database exists at version 1 and you open it with version 2, an upgrade is triggered.
  • You cannot open with a version lower than the current one (this causes an error).

The upgradeneeded Event

The upgradeneeded event fires when:

  1. The database is being created for the first time, or
  2. The version number in open() is higher than the current version.

This is the only place where you can create or delete object stores and indexes:

const request = indexedDB.open('MyAppDB', 1);

request.onupgradeneeded = (event) => {
const db = event.target.result;
console.log(`Upgrading from version ${event.oldVersion} to ${event.newVersion}`);

// Create object stores here
if (!db.objectStoreNames.contains('users')) {
db.createObjectStore('users', { keyPath: 'id' });
}
};

request.onsuccess = (event) => {
const db = event.target.result;
console.log('Database ready');
};

request.onerror = (event) => {
console.error('Error:', event.target.error);
};

Handling Multiple Versions

As your application evolves, you add new object stores or indexes. Use the oldVersion to apply changes incrementally:

const request = indexedDB.open('MyAppDB', 3);

request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;

if (oldVersion < 1) {
// Version 1: Create initial stores
db.createObjectStore('users', { keyPath: 'id' });
db.createObjectStore('settings', { keyPath: 'key' });
}

if (oldVersion < 2) {
// Version 2: Add products store
db.createObjectStore('products', { keyPath: 'sku' });
}

if (oldVersion < 3) {
// Version 3: Add index to users store
const userStore = event.target.transaction.objectStore('users');
userStore.createIndex('by_email', 'email', { unique: true });
}
};

This pattern ensures that whether a user visits your app for the first time (upgrading from version 0 to 3) or is returning after a while (upgrading from version 1 to 3), all the necessary schema changes are applied correctly.

The onblocked Event

If the database is already open in another tab at an older version, the upgrade cannot proceed until the other tab closes its connection. The onblocked event fires in this situation:

const request = indexedDB.open('MyAppDB', 2);

request.onblocked = () => {
console.warn('Database upgrade blocked. Please close other tabs using this app.');
};

request.onupgradeneeded = (event) => {
// Schema changes...
};

To handle this gracefully, listen for the versionchange event on the database object in all tabs:

request.onsuccess = (event) => {
const db = event.target.result;

db.onversionchange = () => {
db.close();
console.log('Database is outdated. Please reload the page.');
// Optionally: location.reload();
};
};
caution

Always handle onblocked and onversionchange. Without these handlers, users with multiple tabs open can experience broken upgrades and stale connections that silently cause errors.

Deleting a Database

To completely delete a database:

const deleteRequest = indexedDB.deleteDatabase('MyAppDB');

deleteRequest.onsuccess = () => {
console.log('Database deleted');
};

deleteRequest.onerror = (event) => {
console.error('Error deleting database:', event.target.error);
};

deleteRequest.onblocked = () => {
console.warn('Deletion blocked. Close all tabs using this database.');
};

Object Stores and Indexes

Object stores are the containers for your data. They are roughly equivalent to tables in a relational database, but they store JavaScript objects directly.

Creating Object Stores

Object stores are created inside the upgradeneeded event. Each store needs a key to uniquely identify records.

Option 1: In-line key with keyPath

The key is a property inside the stored object:

request.onupgradeneeded = (event) => {
const db = event.target.result;

// The "id" property of each object serves as the key
const userStore = db.createObjectStore('users', { keyPath: 'id' });
};

// Later, when adding data:
// store.add({ id: 1, name: 'Alice' }); (the key is extracted from obj.id9

Option 2: In-line key with auto-increment

The database generates an incrementing key automatically:

request.onupgradeneeded = (event) => {
const db = event.target.result;

// Auto-generate "id" for each record
const logStore = db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true });
};

// store.add({ message: 'User logged in' }); (id is auto-assigned (1, 2, 3, ...))

Option 3: Out-of-line key (no keyPath)

The key is specified separately when adding records:

request.onupgradeneeded = (event) => {
const db = event.target.result;

const store = db.createObjectStore('cache');
};

// store.add('some cached value', 'my-cache-key'); (key is the second argument)

Option 4: Out-of-line key with auto-increment

const store = db.createObjectStore('items', { autoIncrement: true });
// store.add({ name: 'Widget' }); (key is auto-generated but NOT stored in the object)

Creating Indexes

Indexes allow you to search records by properties other than the primary key. Without an index, searching by a non-key property would require scanning every record.

request.onupgradeneeded = (event) => {
const db = event.target.result;

const userStore = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });

// Create indexes on the store
userStore.createIndex('by_name', 'name', { unique: false });
userStore.createIndex('by_email', 'email', { unique: true });
userStore.createIndex('by_age', 'age', { unique: false });
};

createIndex(indexName, keyPath, options) takes:

  • indexName: A name to reference the index later.
  • keyPath: The property path to index (can be a string or an array for compound indexes).
  • options: { unique: true/false, multiEntry: true/false }
    • unique: true means no two records can have the same value for this property.
    • multiEntry: true is used when the indexed property is an array. Each array element becomes a separate index entry.

Multi-Entry Indexes

If a property holds an array, multiEntry: true indexes each element individually:

request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore('articles', { keyPath: 'id' });

// Each tag in the "tags" array gets its own index entry
store.createIndex('by_tag', 'tags', { unique: false, multiEntry: true });
};

// store.add({ id: 1, title: 'Learn JS', tags: ['javascript', 'programming', 'web'] });
// Now you can search for articles by any single tag

Deleting Object Stores and Indexes

Both must be done inside upgradeneeded:

request.onupgradeneeded = (event) => {
const db = event.target.result;

// Delete an entire object store
if (db.objectStoreNames.contains('oldStore')) {
db.deleteObjectStore('oldStore');
}

// Delete an index from a store
const store = event.target.transaction.objectStore('users');
if (store.indexNames.contains('old_index')) {
store.deleteIndex('old_index');
}
};

Transactions: readonly and readwrite

Every interaction with data in IndexedDB happens inside a transaction. Transactions ensure that operations are atomic: either all succeed, or none do.

Creating a Transaction

// Open a transaction on one or more object stores
const transaction = db.transaction('users', 'readonly');

// Or on multiple stores:
const transaction2 = db.transaction(['users', 'orders'], 'readwrite');

The second argument is the mode:

ModeDescription
'readonly'Only read operations. Multiple readonly transactions can run simultaneously.
'readwrite'Read and write operations. Only one readwrite transaction per store at a time.

Getting an Object Store from a Transaction

const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

// Now use the store to perform operations
store.add({ id: 1, name: 'Alice' });

Transaction Lifecycle

A transaction auto-completes when all requests are done and no new requests are made. You can listen for completion:

const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

store.add({ id: 1, name: 'Alice' });
store.add({ id: 2, name: 'Bob' });

transaction.oncomplete = () => {
console.log('All operations committed successfully');
};

transaction.onerror = (event) => {
console.error('Transaction failed:', event.target.error);
};

transaction.onabort = () => {
console.log('Transaction was aborted');
};

Manually Aborting a Transaction

You can roll back a transaction at any point before it completes:

const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

store.add({ id: 1, name: 'Alice' });

// Something went wrong, roll everything back
transaction.abort();
// The add() above is undone
warning

Transactions auto-close when control returns to the event loop. You cannot use await, setTimeout, fetch, or any asynchronous operation inside a transaction and then continue using it. Once the transaction's synchronous code and its event handlers finish, the transaction is done.

// WRONG: the transaction will be closed by the time fetch resolves
const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

const response = await fetch('/api/users'); // Transaction dies here!
const users = await response.json();
store.add(users[0]); // Error: transaction is inactive
// CORRECT: fetch data first, then start the transaction
const response = await fetch('/api/users');
const users = await response.json();

const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');
store.add(users[0]); // Works fine

CRUD Operations: add, put, get, getAll, delete, clear

Once you have a transaction and a store, performing data operations is straightforward. Each operation returns an IDBRequest with onsuccess and onerror events.

Create: add(value) and put(value)

add(value) inserts a new record. It fails if a record with the same key already exists:

const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

const request = store.add({ id: 1, name: 'Alice', email: 'alice@example.com' });

request.onsuccess = () => {
console.log('User added with key:', request.result); // 1
};

request.onerror = (event) => {
console.error('Error adding user:', event.target.error);
// If key 1 already exists: "ConstraintError"
};

put(value) inserts or updates. If a record with the same key exists, it is replaced:

const store = db.transaction('users', 'readwrite').objectStore('users');

// Insert if not exists, update if exists
store.put({ id: 1, name: 'Alice Updated', email: 'alice_new@example.com' });

For out-of-line keys (no keyPath), pass the key as the second argument:

store.add('some value', 'my-key');
store.put('updated value', 'my-key');

Read: get(key) and getAll()

get(key) retrieves a single record by its primary key:

const store = db.transaction('users', 'readonly').objectStore('users');

const request = store.get(1);

request.onsuccess = () => {
if (request.result) {
console.log('Found user:', request.result);
// { id: 1, name: 'Alice', email: 'alice@example.com' }
} else {
console.log('User not found');
}
};

getAll() retrieves all records from the store:

const store = db.transaction('users', 'readonly').objectStore('users');

const request = store.getAll();

request.onsuccess = () => {
console.log('All users:', request.result);
// [{ id: 1, name: 'Alice', ... }, { id: 2, name: 'Bob', ... }]
};

getAll() accepts optional arguments for filtering:

// Get all records with keys in range 1-5
store.getAll(IDBKeyRange.bound(1, 5));

// Get at most 10 records
store.getAll(null, 10);

// Get at most 10 records with keys >= 5
store.getAll(IDBKeyRange.lowerBound(5), 10);

getAllKeys() retrieves only the keys, not the full records:

const request = store.getAllKeys();
request.onsuccess = () => {
console.log('All user IDs:', request.result); // [1, 2, 3, 4, 5]
};

getKey(query) retrieves the first key that matches a query:

const request = store.getKey(IDBKeyRange.lowerBound(3));
request.onsuccess = () => {
console.log('First key >= 3:', request.result); // 3
};

count() returns the number of records:

const request = store.count();
request.onsuccess = () => {
console.log('Total users:', request.result); // 5
};

// Count records in a range
store.count(IDBKeyRange.bound(1, 3)); // Count keys between 1 and 3

Update

There is no separate "update" method. Use put() to replace an existing record:

const store = db.transaction('users', 'readwrite').objectStore('users');

// First, get the record
const getRequest = store.get(1);
getRequest.onsuccess = () => {
const user = getRequest.result;
user.name = 'Alice Smith'; // Modify
store.put(user); // Save back
console.log('User updated');
};

Delete: delete(key) and clear()

delete(key) removes a single record:

const store = db.transaction('users', 'readwrite').objectStore('users');

const request = store.delete(1);

request.onsuccess = () => {
console.log('User deleted');
};

You can also delete a range of keys:

// Delete all records with keys from 1 to 5
store.delete(IDBKeyRange.bound(1, 5));

clear() removes all records from the store:

const store = db.transaction('users', 'readwrite').objectStore('users');

const request = store.clear();

request.onsuccess = () => {
console.log('All users deleted');
};

Complete CRUD Example

Here is a full example tying all operations together:

function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('CRUDExample', 1);

request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('tasks')) {
db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
}
};

request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject(event.target.error);
});
}

async function demo() {
const db = await openDB();

// CREATE
const addTx = db.transaction('tasks', 'readwrite');
const addStore = addTx.objectStore('tasks');
addStore.add({ title: 'Learn IndexedDB', done: false });
addStore.add({ title: 'Build a project', done: false });
addStore.add({ title: 'Deploy to production', done: false });

addTx.oncomplete = () => {
console.log('Tasks added');

// READ ALL
const readTx = db.transaction('tasks', 'readonly');
const readStore = readTx.objectStore('tasks');
const getAllReq = readStore.getAll();

getAllReq.onsuccess = () => {
console.log('All tasks:', getAllReq.result);
// [
// { id: 1, title: 'Learn IndexedDB', done: false },
// { id: 2, title: 'Build a project', done: false },
// { id: 3, title: 'Deploy to production', done: false }
// ]
};

// UPDATE
const updateTx = db.transaction('tasks', 'readwrite');
const updateStore = updateTx.objectStore('tasks');
const getReq = updateStore.get(1);

getReq.onsuccess = () => {
const task = getReq.result;
task.done = true;
updateStore.put(task);
};

updateTx.oncomplete = () => {
console.log('Task 1 marked as done');

// DELETE
const deleteTx = db.transaction('tasks', 'readwrite');
const deleteStore = deleteTx.objectStore('tasks');
deleteStore.delete(3);

deleteTx.oncomplete = () => {
console.log('Task 3 deleted');
};
};
};
}

demo();

Output:

Tasks added
All tasks: [
{ id: 1, title: 'Learn IndexedDB', done: false },
{ id: 2, title: 'Build a project', done: false },
{ id: 3, title: 'Deploy to production', done: false }
]
Task 1 marked as done
Task 3 deleted
tip

Notice how the nested callbacks make the code hard to follow. This is exactly why promise-based wrappers (covered at the end of this guide) are so popular for IndexedDB.

Cursors and Range Queries

When you need to iterate over records one by one, or process a subset of records based on key ranges, cursors are the tool.

What Is a Cursor?

A cursor is a pointer that walks through records in an object store or index, one at a time. Unlike getAll(), which loads everything into memory at once, cursors process records incrementally, making them suitable for large datasets.

Opening a Cursor

const store = db.transaction('users', 'readonly').objectStore('users');

const request = store.openCursor();

request.onsuccess = (event) => {
const cursor = event.target.result;

if (cursor) {
console.log('Key:', cursor.key);
console.log('Value:', cursor.value);
cursor.continue(); // Move to the next record
} else {
console.log('No more records');
}
};

The cursor object has these properties:

PropertyDescription
cursor.keyThe primary key of the current record
cursor.valueThe full record object
cursor.primaryKeySame as key for object stores, the primary key for index cursors

And these methods:

MethodDescription
cursor.continue()Advance to the next record
cursor.continue(key)Skip to a specific key
cursor.advance(n)Skip n records
cursor.update(value)Update the current record (readwrite mode only)
cursor.delete()Delete the current record (readwrite mode only)

Key Ranges with IDBKeyRange

IDBKeyRange lets you specify which records to include when opening a cursor or using getAll():

// All keys >= 5
IDBKeyRange.lowerBound(5);

// All keys > 5 (exclusive)
IDBKeyRange.lowerBound(5, true);

// All keys <= 10
IDBKeyRange.upperBound(10);

// All keys < 10 (exclusive)
IDBKeyRange.upperBound(10, true);

// All keys from 5 to 10 (inclusive on both ends)
IDBKeyRange.bound(5, 10);

// All keys from 5 (exclusive) to 10 (inclusive)
IDBKeyRange.bound(5, 10, true, false);

// Exactly key 5
IDBKeyRange.only(5);

Using Key Ranges with Cursors

const store = db.transaction('products', 'readonly').objectStore('products');

// Iterate only over products with price between 10 and 50
// (assuming keyPath is 'price' or we use an index, see next section)

// For primary key ranges:
const range = IDBKeyRange.bound(5, 20); // IDs 5 through 20
const request = store.openCursor(range);

request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
console.log(`ID ${cursor.key}: ${cursor.value.name}`);
cursor.continue();
}
};

Cursor Direction

By default, cursors iterate in ascending key order. You can change the direction:

// Ascending (default)
store.openCursor(null, 'next');

// Descending
store.openCursor(null, 'prev');

// Ascending, skip duplicate keys (for index cursors)
store.openCursor(null, 'nextunique');

// Descending, skip duplicate keys (for index cursors)
store.openCursor(null, 'prevunique');

Example of reverse iteration:

const store = db.transaction('users', 'readonly').objectStore('users');
const request = store.openCursor(null, 'prev');

request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
console.log(`User ${cursor.value.name} (ID: ${cursor.key})`);
cursor.continue();
}
};
// Output (descending order):
// User Charlie (ID: 3)
// User Bob (ID: 2)
// User Alice (ID: 1)

Updating and Deleting with Cursors

Cursors in readwrite transactions can modify data on the fly:

const store = db.transaction('tasks', 'readwrite').objectStore('tasks');
const request = store.openCursor();

request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const task = cursor.value;

if (task.done) {
// Delete completed tasks
cursor.delete();
console.log(`Deleted completed task: ${task.title}`);
} else if (task.priority === undefined) {
// Add a default priority to tasks missing it
task.priority = 'normal';
cursor.update(task);
console.log(`Updated task: ${task.title}`);
}

cursor.continue();
} else {
console.log('Cleanup complete');
}
};

openKeyCursor

If you only need keys (not values), use openKeyCursor() for better performance:

const store = db.transaction('users', 'readonly').objectStore('users');
const request = store.openKeyCursor();

request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
console.log('Key:', cursor.key); // No cursor.value available
cursor.continue();
}
};

Searching by Index

Indexes are essential for searching records by properties other than the primary key. Without an index, you would need to open a cursor and scan every record.

Setting Up Indexes

Recall from the earlier section:

request.onupgradeneeded = (event) => {
const db = event.target.result;

const store = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
store.createIndex('by_name', 'name', { unique: false });
store.createIndex('by_email', 'email', { unique: true });
store.createIndex('by_age', 'age', { unique: false });
};

Querying an Index

Access an index from the store and use the same methods (get, getAll, openCursor, count):

const store = db.transaction('users', 'readonly').objectStore('users');

// Get the index
const emailIndex = store.index('by_email');

// Find a user by email
const request = emailIndex.get('alice@example.com');

request.onsuccess = () => {
console.log('Found:', request.result);
// { id: 1, name: 'Alice', email: 'alice@example.com', age: 30 }
};

Getting All Records Matching an Index Value

const store = db.transaction('users', 'readonly').objectStore('users');
const nameIndex = store.index('by_name');

// Get all users named "Bob" (non-unique index can return multiple results)
const request = nameIndex.getAll('Bob');

request.onsuccess = () => {
console.log('All Bobs:', request.result);
// [{ id: 2, name: 'Bob', ... }, { id: 7, name: 'Bob', ... }]
};

Index with Key Ranges

Combine indexes with IDBKeyRange for powerful queries:

const store = db.transaction('users', 'readonly').objectStore('users');
const ageIndex = store.index('by_age');

// Find all users aged 25 to 35
const range = IDBKeyRange.bound(25, 35);
const request = ageIndex.getAll(range);

request.onsuccess = () => {
console.log('Users aged 25-35:', request.result);
};

Cursor on an Index

You can open a cursor on an index to iterate through records in index order:

const store = db.transaction('users', 'readonly').objectStore('users');
const nameIndex = store.index('by_name');

// Iterate users in alphabetical order by name
const request = nameIndex.openCursor();

request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
console.log(`${cursor.value.name} (age: ${cursor.value.age})`);
// cursor.key = the indexed value (name)
// cursor.primaryKey = the primary key (id)
// cursor.value = the full record
cursor.continue();
}
};
// Output:
// Alice (age: 30)
// Bob (age: 28)
// Charlie (age: 35)

You can search for strings starting with a prefix using a range trick:

const store = db.transaction('users', 'readonly').objectStore('users');
const nameIndex = store.index('by_name');

// Find all names starting with "Al"
const range = IDBKeyRange.bound('Al', 'Al\uffff');
const request = nameIndex.getAll(range);

request.onsuccess = () => {
console.log('Names starting with "Al":', request.result);
// Alice, Albert, etc.
};

The \uffff character is the highest possible Unicode character in BMP, ensuring the range captures all strings starting with "Al".

tip

IndexedDB does not support full-text search, regular expressions, or LIKE queries. For complex text searching, consider building a separate search index, or use a library like Dexie.js that provides higher-level query capabilities.

Handling Errors and Transactions

Error handling in IndexedDB follows a bubbling pattern similar to DOM events. Understanding this is critical for robust applications.

Error Bubbling

When a request fails, the error event:

  1. Fires on the request (request.onerror)
  2. Bubbles up to the transaction (transaction.onerror)
  3. Bubbles up to the database (db.onerror)
const db = await openDB();

// Global error handler for the database connection
db.onerror = (event) => {
console.error('Database error:', event.target.error);
};

const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

// Try to add a duplicate key
const request = store.add({ id: 1, name: 'Duplicate' });

request.onerror = (event) => {
console.error('Request error:', event.target.error.name); // "ConstraintError"
// To prevent the error from bubbling to the transaction:
event.preventDefault();
event.stopPropagation();
};

transaction.onerror = (event) => {
// Only fires if request.onerror didn't call preventDefault()
console.error('Transaction error:', event.target.error);
};

transaction.onabort = () => {
console.log('Transaction aborted');
};

Preventing Transaction Abort on Error

By default, an unhandled request error aborts the entire transaction, rolling back all changes. If you want to handle the error and continue the transaction, call event.preventDefault():

const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

// Add first user (succeeds)
store.add({ id: 1, name: 'Alice' });

// Try to add duplicate (fails)
const dupRequest = store.add({ id: 1, name: 'Duplicate Alice' });
dupRequest.onerror = (event) => {
console.log('Duplicate detected, skipping...');
event.preventDefault(); // Don't abort the transaction
};

// Add second user (still succeeds because we prevented abort)
store.add({ id: 2, name: 'Bob' });

transaction.oncomplete = () => {
console.log('Transaction completed. Alice and Bob added, duplicate skipped.');
};

Common Error Types

Error NameCause
ConstraintErrorUnique constraint violated (duplicate key or unique index)
ReadOnlyErrorAttempting to write in a readonly transaction
TransactionInactiveErrorUsing a transaction that has already completed
DataErrorInvalid key or key range
InvalidStateErrorOperation not allowed in current state
QuotaExceededErrorStorage quota exceeded
AbortErrorTransaction was aborted

Handling Quota Exceeded

When the browser's storage limit is reached:

const request = store.add(largeData);

request.onerror = (event) => {
if (event.target.error.name === 'QuotaExceededError') {
console.error('Storage full! Consider clearing old data.');
// Implement cleanup logic
event.preventDefault();
}
};

The transaction.oncomplete vs request.onsuccess Distinction

A common source of confusion:

  • request.onsuccess: The individual operation succeeded, but the data is not yet guaranteed to be on disk. The transaction can still abort (due to later errors or explicit abort()).
  • transaction.oncomplete: All operations in the transaction have been committed to disk. The data is safely persisted.
const transaction = db.transaction('users', 'readwrite');
const store = transaction.objectStore('users');

const addRequest = store.add({ id: 1, name: 'Alice' });

addRequest.onsuccess = () => {
// The add succeeded, but the data is not yet permanently saved
console.log('Add succeeded (but not yet committed)');
};

transaction.oncomplete = () => {
// NOW the data is safely on disk
console.log('Transaction committed. Data is permanent.');
};

transaction.onabort = () => {
// Something went wrong, all changes rolled back
console.log('Transaction aborted. Nothing was saved.');
};
caution

Always rely on transaction.oncomplete for confirming that data has been saved. A request.onsuccess only means the individual operation passed validation. The transaction could still be aborted.

Promisified Wrappers (idb Library)

As you have seen, the native IndexedDB API is verbose and relies heavily on nested callbacks. For modern JavaScript development, promise-based wrappers make IndexedDB dramatically easier to work with.

The Problem with Raw IndexedDB

Here is what fetching and displaying all users looks like with the raw API:

const request = indexedDB.open('MyDB', 1);

request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction('users', 'readonly');
const store = transaction.objectStore('users');
const getAllRequest = store.getAll();

getAllRequest.onsuccess = () => {
console.log(getAllRequest.result);
};

getAllRequest.onerror = (event) => {
console.error(event.target.error);
};
};

request.onerror = (event) => {
console.error(event.target.error);
};

This is a lot of code for a simple "get all records" operation.

The idb Library

The most popular wrapper is idb by Jake Archibald (a Chrome developer). It wraps every IndexedDB operation in a Promise, letting you use async/await:

Install it:

npm install idb

Opening a Database with idb

import { openDB } from 'idb';

const db = await openDB('MyAppDB', 1, {
upgrade(db, oldVersion, newVersion, transaction) {
if (oldVersion < 1) {
const store = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
store.createIndex('by_email', 'email', { unique: true });
store.createIndex('by_age', 'age');
}
},
blocked() {
console.warn('Database upgrade blocked by another tab');
},
blocking() {
console.warn('This connection is blocking a version upgrade');
},
});

CRUD with idb

Everything becomes clean async/await code:

import { openDB } from 'idb';

async function demo() {
const db = await openDB('MyAppDB', 1, {
upgrade(db) {
const store = db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
store.createIndex('by_status', 'status');
}
});

// CREATE
const id1 = await db.add('tasks', { title: 'Learn IndexedDB', status: 'done' });
const id2 = await db.add('tasks', { title: 'Build a project', status: 'in-progress' });
const id3 = await db.add('tasks', { title: 'Deploy', status: 'todo' });
console.log('Added tasks with IDs:', id1, id2, id3);

// READ ONE
const task = await db.get('tasks', id1);
console.log('Task 1:', task);
// { id: 1, title: 'Learn IndexedDB', status: 'done' }

// READ ALL
const allTasks = await db.getAll('tasks');
console.log('All tasks:', allTasks);

// UPDATE
const taskToUpdate = await db.get('tasks', id2);
taskToUpdate.status = 'done';
await db.put('tasks', taskToUpdate);
console.log('Task 2 updated');

// DELETE
await db.delete('tasks', id3);
console.log('Task 3 deleted');

// COUNT
const count = await db.count('tasks');
console.log('Remaining tasks:', count); // 2

// SEARCH BY INDEX
const doneTasks = await db.getAllFromIndex('tasks', 'by_status', 'done');
console.log('Done tasks:', doneTasks);

// TRANSACTION (multiple operations atomically)
const tx = db.transaction('tasks', 'readwrite');
await Promise.all([
tx.store.add({ title: 'Task A', status: 'todo' }),
tx.store.add({ title: 'Task B', status: 'todo' }),
tx.store.add({ title: 'Task C', status: 'todo' }),
tx.done // Wait for transaction to complete
]);
console.log('Batch insert complete');

// CURSOR ITERATION
let cursor = await db.transaction('tasks').store.openCursor();
while (cursor) {
console.log(`Cursor at key ${cursor.key}:`, cursor.value.title);
cursor = await cursor.continue();
}
}

demo();

Output:

Added tasks with IDs: 1 2 3
Task 1: { id: 1, title: 'Learn IndexedDB', status: 'done' }
All tasks: [
{ id: 1, title: 'Learn IndexedDB', status: 'done' },
{ id: 2, title: 'Build a project', status: 'in-progress' },
{ id: 3, title: 'Deploy', status: 'todo' }
]
Task 2 updated
Task 3 deleted
Remaining tasks: 2
Done tasks: [
{ id: 1, title: 'Learn IndexedDB', status: 'done' },
{ id: 2, title: 'Build a project', status: 'done' }
]
Batch insert complete
Cursor at key 1: Learn IndexedDB
Cursor at key 2: Build a project
Cursor at key 4: Task A
Cursor at key 5: Task B
Cursor at key 6: Task C

Comparison: Raw API vs. idb

OperationRaw IndexedDBidb
Open DBindexedDB.open() + onsuccess callbackawait openDB()
Add recordTransaction + store + add() + onsuccessawait db.add(storeName, value)
Get recordTransaction + store + get() + onsuccessawait db.get(storeName, key)
Get allTransaction + store + getAll() + onsuccessawait db.getAll(storeName)
Search by indexTransaction + store + index + getAll() + onsuccessawait db.getAllFromIndex(store, idx, val)
DeleteTransaction + store + delete() + onsuccessawait db.delete(storeName, key)
CursoropenCursor() + recursive onsuccessawait cursor.continue() in a loop

Building Your Own Simple Wrapper

If you do not want to add a dependency, here is a minimal promise wrapper:

function promisifyRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

function promisifyTransaction(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new DOMException('AbortError'));
});
}

// Usage
async function openMyDB() {
return promisifyRequest(indexedDB.open('MyDB', 1));
}

async function addUser(db, user) {
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
store.add(user);
await promisifyTransaction(tx);
}

async function getUser(db, id) {
const tx = db.transaction('users', 'readonly');
const store = tx.objectStore('users');
return promisifyRequest(store.get(id));
}

async function getAllUsers(db) {
const tx = db.transaction('users', 'readonly');
const store = tx.objectStore('users');
return promisifyRequest(store.getAll());
}
LibraryDescriptionSize
idbThin promise wrapper by Jake Archibald~1.2 KB
Dexie.jsFull-featured, with rich query API, live queries~30 KB
localForageUnified API (falls back to localStorage/WebSQL)~10 KB
PouchDBFull database with sync to CouchDB~46 KB

For most projects, idb provides the best balance between simplicity and power. If you need complex queries, relationships, or live query reactivity, consider Dexie.js.

tip

The recommendation for production code: Always use idb or a similar wrapper instead of the raw IndexedDB API. The raw API is important to understand (so you know what is happening under the hood), but writing application code with it is unnecessarily painful and error-prone.

Summary

IndexedDB is the most powerful client-side storage mechanism available in the browser. Here are the key points:

  • IndexedDB is a transactional, asynchronous, NoSQL database that stores JavaScript objects directly and supports large amounts of data.
  • Database versioning is managed through integer version numbers. The upgradeneeded event is the only place to create or modify object stores and indexes.
  • Object stores hold records identified by keys (in-line via keyPath or out-of-line). Indexes enable fast lookups on non-key properties.
  • Transactions (readonly or readwrite) wrap all data operations. They are atomic: either everything commits, or everything rolls back.
  • CRUD operations: add() inserts (fails on duplicate keys), put() inserts or replaces, get() and getAll() read, delete() and clear() remove.
  • Cursors let you iterate through records one at a time, with support for key ranges (IDBKeyRange) and reverse direction.
  • Indexes allow searching by any property, not just the primary key. Combine indexes with IDBKeyRange for range queries.
  • Error handling follows a bubbling pattern (request to transaction to database). Use event.preventDefault() to handle errors without aborting the transaction.
  • Use idb or Dexie.js in production to avoid the verbose callback-based native API and write clean async/await code.