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.

Creating object stores

Object stores are like database tables. Within the upgrade callback, create them:

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.

Adding data

Transactions are the heart of IndexedDB. Every operation happens within a transaction:

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).

Retrieving data

Get data by key, or use cursors to iterate:

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 them to find records by any property:

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);
  });
}

Updating and deleting data

Same pattern, different operation:

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);
  });
}

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:

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);
  }
});

Storing blobs and files

IndexedDB handles binary data natively. This is perfect for caching images or offline file storage:

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);

Database versioning and migrations

Increment your database version number when schema changes:

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.

A complete example

Here’s a mini library that wraps IndexedDB with promises:

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.

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.