IndexedDB is the browser's built-in database — it can store gigabytes of structured data, works offline, and supports transactions. It's more powerful than localStorage but more complex. This guide covers the essentials: creating a database, CRUD operations, indexes, and a modern wrapper pattern.
Why IndexedDB?
| Feature | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|
| Capacity | ~5MB | ~5MB | Hundreds of MB+ |
| Data types | Strings only | Strings only | Any structured-clone-able value |
| Async | No (blocks UI) | No | Yes |
| Transactions | No | No | Yes |
| Indexes/queries | No | No | Yes |
| Works offline | Yes | No | Yes |
Use IndexedDB for: offline apps, large datasets, structured records, caching API responses with queries.
Opening a Database
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MyApp', 1); // name, version
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object stores (like tables) here
if (!db.objectStoreNames.contains('tasks')) {
const store = db.createObjectStore('tasks', {
keyPath: 'id', // Primary key field
autoIncrement: false, // We'll provide IDs
});
// Create indexes for querying
store.createIndex('by-status', 'status', { unique: false });
store.createIndex('by-due-date', 'dueDate', { unique: false });
store.createIndex('by-user', 'userId', { unique: false });
}
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject(event.target.error);
});
}
onupgradeneeded runs when the database is created or when you increment the version number. This is the only place you can create/modify object stores.
CRUD Operations
Add a Record
async function addTask(db, task) {
return new Promise((resolve, reject) => {
const transaction = db.transaction('tasks', 'readwrite');
const store = transaction.objectStore('tasks');
const request = store.add(task); // Fails if key exists
// Or: store.put(task); // Upsert (add or update)
request.onsuccess = () => resolve(request.result); // Returns the key
request.onerror = () => reject(request.error);
});
}
// Usage
const db = await openDatabase();
await addTask(db, {
id: 'task-001',
title: 'Write documentation',
status: 'pending',
userId: 'user-1',
dueDate: '2026-10-30',
createdAt: Date.now(),
});
Get by Key
async function getTask(db, id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction('tasks', 'readonly');
const store = transaction.objectStore('tasks');
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Update
async function updateTask(db, id, updates) {
return new Promise(async (resolve, reject) => {
const transaction = db.transaction('tasks', 'readwrite');
const store = transaction.objectStore('tasks');
// Get existing, then put updated
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const task = { ...getRequest.result, ...updates, updatedAt: Date.now() };
const putRequest = store.put(task);
putRequest.onsuccess = () => resolve(task);
putRequest.onerror = () => reject(putRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
});
}
Delete
async function deleteTask(db, id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction('tasks', 'readwrite');
const store = transaction.objectStore('tasks');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
Querying with Indexes
Without indexes, you can only get records by their primary key. Indexes let you query by any field:
// Get all tasks with status='pending'
async function getTasksByStatus(db, status) {
return new Promise((resolve, reject) => {
const transaction = db.transaction('tasks', 'readonly');
const store = transaction.objectStore('tasks');
const index = store.index('by-status');
// getAll on index
const request = index.getAll(status); // All matching records
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get tasks in a date range using cursor
async function getTasksDue(db, startDate, endDate) {
return new Promise((resolve, reject) => {
const transaction = db.transaction('tasks', 'readonly');
const index = transaction.objectStore('tasks').index('by-due-date');
const range = IDBKeyRange.bound(startDate, endDate); // 'startDate' <= key <= 'endDate'
const results = [];
const request = index.openCursor(range);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results); // No more records
}
};
request.onerror = () => reject(request.error);
});
}
IDBKeyRange Options
IDBKeyRange.only('pending') // Exact match
IDBKeyRange.lowerBound('2026-01-01') // >= date
IDBKeyRange.upperBound('2026-12-31') // <= date
IDBKeyRange.bound('2026-01-01', '2026-12-31') // between (inclusive)
IDBKeyRange.bound('2026-01-01', '2026-12-31', true, true) // exclusive bounds
Modern Wrapper: idb Library
The raw IndexedDB API is verbose. The idb library (from Jake Archibald) wraps it with Promises:
import { openDB } from 'idb';
const db = await openDB('MyApp', 1, {
upgrade(db) {
const store = db.createObjectStore('tasks', { keyPath: 'id' });
store.createIndex('by-status', 'status');
},
});
// CRUD without callbacks
await db.add('tasks', { id: 'task-1', title: 'Test', status: 'pending' });
const task = await db.get('tasks', 'task-1');
await db.put('tasks', { ...task, status: 'done' });
await db.delete('tasks', 'task-1');
// Index queries
const pending = await db.getAllFromIndex('tasks', 'by-status', 'pending');
Much cleaner. idb is ~1KB gzipped.
Firefox DevTools: Storage Inspector
View and edit IndexedDB data:
- Open DevTools (F12)
- Go to Storage tab
- Expand IndexedDB in the left panel
- Navigate: database → object store → records
You can add, edit, and delete records directly from the Storage inspector — great for debugging.
Transactions and Error Handling
IndexedDB transactions auto-commit when all requests complete:
async function transferPoints(db, fromId, toId, amount) {
return new Promise((resolve, reject) => {
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
// Both operations in the same transaction — atomic
const getFrom = store.get(fromId);
getFrom.onsuccess = () => {
const from = getFrom.result;
if (from.points < amount) {
tx.abort(); // Roll back the transaction
reject(new Error('Insufficient points'));
return;
}
store.put({ ...from, points: from.points - amount });
const getTo = store.get(toId);
getTo.onsuccess = () => {
const to = getTo.result;
store.put({ ...to, points: to.points + amount });
};
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(new Error('Transaction aborted'));
});
}
IndexedDB is the right choice for any browser app that needs real structured data persistence.
Top comments (0)