IndexedDB: Client-Side Databases
IndexedDB is a transactional, object-oriented database built into your browser. Unlike localStorage, which only handles strings, IndexedDB can store complex JavaScript objects, handle millions of records, and support powerful queries. It’s the backbone of offline-first web applications.
Why IndexedDB?
localStorage gives you 5MB of key-value storage—great for small amounts of data, but limiting for real applications. IndexedDB offers:
- 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 serves as 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:
- Open databases with
indexedDB.open(name, version)and handle upgrades - Create object stores with primary keys and indexes in the upgrade callback
- Use transactions for every operation—readwrite or readonly
- Query efficiently using indexes instead of scanning all records
- Handle large data with cursors that iterate without loading everything
- 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.