jsguides

IndexedDB Client-Side Databases: A Practical Browser Storage Tutorial

Intro context

IndexedDB client-side databases are how browsers store structured data that outlives a refresh. IndexedDB is a transactional, object-oriented database built into every modern browser. Unlike localStorage, which only handles strings, IndexedDB can store complex JavaScript objects, handle millions of records, and support indexed queries. It’s the storage backbone for offline-first web apps.

This tutorial walks through opening a database, declaring object stores and indexes during the upgradeneeded event, running read and write transactions, fetching records by key and by index, iterating with cursors, and handling schema migrations. For background on simpler storage, see the browser storage tutorial and the JavaScript IndexedDB guide.

Why indexeddb?

localStorage gives you 5MB of key-value storage, which is fine for tiny preferences but limiting for real applications. IndexedDB client-side databases instead offer:

  • Virtually unlimited storage (user-controlled)
  • Complex data types , store objects, dates, blobs
  • Transactional integrity , atomic operations prevent data corruption
  • Query support , search and filter your data
  • Cursor iteration , process large datasets without loading everything into memory

If you’ve used databases before, IndexedDB will feel familiar. It has databases, object stores, indexes, and transactions.

Opening a database

IndexedDB operations are asynchronous. You start by opening a database:

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

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

request.onsuccess = (event) => {
  const db = event.target.result;
  console.log('Database opened:', db.name, db.version);
};

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  // Create object stores here
  console.log('Database upgrade needed');
};

The onupgradeneeded event fires when the database is created or versioned up, this is where you define your schema. Every object store and index must be created inside this callback.

Creating object stores

Object stores are like database tables. Within the upgrade callback, create them. Each store needs a primary key path, and indexes let you query by fields other than the primary key — define those at creation time as well:

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

  // Create an object store called 'users'
  const userStore = db.createObjectStore('users', { keyPath: 'id' });

  // Create an index for searching by email
  userStore.createIndex('email', 'email', { unique: true });

  // Create an index for searching by name (non-unique)
  userStore.createIndex('name', 'name', { unique: false });
};

The keyPath option designates which property is the primary key. You can also use autoIncrement: true to generate keys automatically. Indexes defined here are what you will use later for all non-primary-key lookups.

Adding data

Transactions are the heart of IndexedDB. Every read or write operation happens within a transaction, and you must specify which object stores the transaction covers. Wrapping operations in promises makes the async flow easier to compose:

function addUser(db, user) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');

    const request = store.add(user);

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

// Usage
const db = await openDatabase();
await addUser(db, {
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
  createdAt: new Date()
});

The transaction takes two arguments: an array of object stores to access and the transaction mode (readonly or readwrite). Use the narrowest scope and mode that fits the operation — a readonly transaction on a single store is faster than a readwrite transaction spanning multiple stores.

Retrieving data

Get data by key with store.get(id), or fetch every record in a store with store.getAll(). Both return results through the request’s onsuccess handler, so wrapping them in promises keeps the calling code clean:

function getUser(db, id) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const request = store.get(id);

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

function getAllUsers(db) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const request = store.getAll();

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

Querying with indexes

Indexes make lookups efficient. Use store.index('name') to open an index, then call get() or getAll() on it just like you would on the object store itself. An index declared unique: true guarantees at most one record per value, which is essential for fields like email addresses:

function getUserByEmail(db, email) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const index = store.index('email');

    const request = index.get(email);

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

// Find all users named "Bob" (non-unique index)
function getUsersByName(db, name) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const index = store.index('name');

    const request = index.getAll(name);

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

The transaction wraps both operations, and the promise pattern is the same regardless of which store method you call. The key difference between add() and put() matters: add() fails if the key already exists, while put() overwrites it — so use put() for updates and add() only for fresh inserts.

Updating and deleting data

Same pattern, different operation. store.put() inserts or overwrites a record at a key, and store.delete() removes one. Both require a readwrite transaction since they modify the store:

function updateUser(db, user) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');
    const request = store.put(user); // add() fails if key exists; put() updates

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

function deleteUser(db, id) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');
    const request = store.delete(id);

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

Both put() and delete() need a readwrite transaction. The promise wrapper keeps error handling uniform across all your data-access functions, which matters when you chain operations later.

Processing large datasets with cursors

When you have thousands of records, loading them all at once is wasteful. Cursors let you iterate one record at a time, calling cursor.continue() to advance. This pattern is the right choice for filtering, transforming, or migrating data in bulk without pulling the entire store into memory:

function processAllUsers(db, callback) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const request = store.openCursor();

    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        callback(cursor.value);
        cursor.continue(); // Move to next record
      } else {
        resolve(); // Done
      }
    };

    request.onerror = () => reject(request.error);
  });
}

// Usage: Find users created in the last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

await processAllUsers(db, (user) => {
  if (user.createdAt > thirtyDaysAgo) {
    console.log('Recent user:', user.name);
  }
});

A cursor also gives you access to cursor.key and cursor.primaryKey alongside cursor.value. For indexed lookups that return many records, open the cursor on the index instead of the store to walk only the matching subset.

Storing blobs and files

IndexedDB handles binary data natively. This is perfect for caching images or offline file storage. Store a filename, the blob or file object, and a timestamp so you can later evict stale entries:

async function storeImage(db, name, blob) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['images'], 'readwrite');
    const store = transaction.objectStore('images');

    const request = store.put({ name, blob, storedAt: new Date() });

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

// Usage: Cache an image from the network
const response = await fetch('/api/user-avatar');
const blob = await response.blob();
await storeImage(db, 'avatar-123', blob);

Binary storage makes IndexedDB useful as a cache layer — you can fetch a resource once and serve it from the local database on subsequent visits without touching the network.

Database versioning and migrations

Increment your database version number when schema changes. The browser compares the requested version with the existing database and fires onupgradeneeded only when the requested version is higher — this is your migration hook:

const request = indexedDB.open('myAppDatabase', 2); // Version 2

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

  // Version 1 schema already exists, now add version 2 changes
  if (!db.objectStoreNames.contains('posts')) {
    const postStore = db.createObjectStore('posts', { keyPath: 'id' });
    postStore.createIndex('author', 'author', { unique: false });
    postStore.createIndex('published', 'publishedAt', { unique: false });
  }
};

The upgrade callback is the only place you can create or delete object stores and indexes. If you need to add a new store but the database is already at the target version, bump the version number — even by a fraction — to trigger the callback.

A complete example

Here’s a mini library that wraps IndexedDB with promises. The class pattern hides transaction boilerplate behind get, getAll, put, and delete methods, so the calling code reads cleanly:

class IndexedDB {
  constructor(name, version, upgradeCallback) {
    this.name = name;
    this.version = version;
    this.upgradeCallback = upgradeCallback;
    this.db = null;
  }

  async open() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.name, this.version);

      request.onupgradeneeded = (event) => {
        this.db = event.target.result;
        this.upgradeCallback(this.db, event.oldVersion);
      };

      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve(this.db);
      };

      request.onerror = () => reject(request.error);
    });
  }

  async get(storeName, key) {
    return this._op(storeName, 'readonly', store => store.get(key));
  }

  async getAll(storeName) {
    return this._op(storeName, 'readonly', store => store.getAll());
  }

  async put(storeName, value) {
    return this._op(storeName, 'readwrite', store => store.put(value));
  }

  async delete(storeName, key) {
    return this._op(storeName, 'readwrite', store => store.delete(key));
  }

  _op(storeName, mode, operation) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], mode);
      const store = transaction.objectStore(storeName);
      const request = operation(store);

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

// Usage
const db = new IndexedDB('blog', 1, (db) => {
  const posts = db.createObjectStore('posts', { keyPath: 'id' });
  posts.createIndex('slug', 'slug', { unique: true });
});

await db.open();
await db.put('posts', { id: 1, title: 'Hello World', slug: 'hello-world' });
const posts = await db.getAll('posts');

Browser support and polyfills

IndexedDB works in all modern browsers and IE10+. For older browsers, libraries like idb provide Promise-based wrappers with automatic polyfilling.

Summary

IndexedDB brings real database power to the browser:

  1. Open databases with indexedDB.open(name, version) and handle upgrades
  2. Create object stores with primary keys and indexes in the upgrade callback
  3. Use transactions for every operation, readwrite or readonly
  4. Query efficiently using indexes instead of scanning all records
  5. Handle large data with cursors that iterate without loading everything
  6. Store binary data including blobs and files

Combined with Service Workers for network interception, IndexedDB enables fully offline web applications that sync when connectivity returns.

Designing a durable schema

The hardest part of IndexedDB is usually not the API calls. It is the shape of the data model. A stable schema starts with choosing object store boundaries that match the way your app actually reads and writes data. If you need users, posts, and images to move independently, each one probably deserves its own store. That separation keeps transactions focused and makes future migrations easier to reason about.

Indexes are worth planning early because they tell you how the app will search later. A field that is easy to filter in code may still be expensive to scan once the database grows. Adding an index for the values you look up most often can save a lot of work and reduce the temptation to pull everything into memory. When you think about access paths up front, the schema tends to stay leaner.

Transactions are the safety net that keeps IndexedDB practical. They help you keep reads and writes grouped together so one failure does not leave the database half updated. That matters a lot for apps that support offline use, sync queues, or incremental edits. A reliable transaction pattern makes the code easier to test because each operation has a clear start and finish.

It is also worth planning for change. A version bump is not a failure, it is part of the design of the database. If you expect the structure to evolve, write upgrade logic that can add stores or indexes without breaking existing data. A careful migration path is one of the biggest differences between a toy database and a durable one.

Finally, think about what the user should notice when the database is unavailable or still upgrading. A graceful loading state, a clear retry path, or a fallback to another store can make the app feel steady even when storage is still coming online. That kind of care turns IndexedDB from a hidden implementation detail into a reliable part of the product.

Where to go next

Once you’re comfortable with IndexedDB client-side databases, pair the API with other browser tooling: see the browser storage tutorial for choosing between storage backends, the browser service workers tutorial for caching alongside IndexedDB, and the JavaScript IndexedDB guide for production patterns.

One last habit makes IndexedDB easier to live with: keep the schema and the migration story close together in the codebase. When a future change needs a new index or a new store, the upgrade path should be easy to find and easy to read. That reduces the chance of data loss and makes the database feel like a planned part of the app instead of a hidden surprise.

See Also