IndexedDB: Client-Side Databases

· 6 min read · Updated March 23, 2026 · beginner
javascript browser storage indexeddb api

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 for onsuccess.
  • onupgradeneeded is the only place for schema changes. Calling createObjectStore() anywhere else throws an error.
  • Version must be an integer. Using 1.5 or 2.1 causes failures. Always use Math.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() vs put(): add throws on duplicate keys, put overwrites them. Use put() for upserts.

See Also