JavaScript IndexedDB: Client-Side Database
IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files and blobs. Unlike localStorage and sessionStorage, which store only strings and max out...
Key Insights
- IndexedDB provides a transactional NoSQL database in the browser capable of storing structured data, files, and blobs—far exceeding localStorage’s 5-10MB limit with storage in the hundreds of megabytes or more
- The API is event-driven and low-level by design, requiring explicit transaction management and version control, but this complexity enables powerful features like indexes, cursors, and atomic operations
- Modern offline-first applications and PWAs rely on IndexedDB for caching API responses, storing user-generated content, and maintaining app state during network failures
Introduction to IndexedDB
IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files and blobs. Unlike localStorage and sessionStorage, which store only strings and max out around 5-10MB, IndexedDB can handle hundreds of megabytes or even gigabytes depending on browser implementation and available disk space.
The primary use cases are offline-first applications, Progressive Web Apps (PWAs), caching large datasets for performance, and storing user-generated content that needs to persist across sessions. If you’re building a web app that needs to function without network connectivity or handle complex data relationships, IndexedDB is your solution.
localStorage is fine for simple key-value pairs like user preferences. IndexedDB is what you reach for when you need a real database with querying capabilities, transactions, and structured data storage.
Core Concepts and Architecture
IndexedDB follows a hierarchical structure: databases contain object stores, which contain records. Think of object stores as tables in a relational database, though IndexedDB is NoSQL and schema-less within each record.
Key concepts:
- Database: Top-level container with a name and version number
- Object Store: Collection of records, similar to a table
- Index: Secondary lookup mechanism for querying by non-key fields
- Transaction: Wrapper for database operations ensuring atomicity
- Key Path: Property used as the primary key for records
- Cursor: Iterator for traversing multiple records
The API is entirely asynchronous and event-driven. Every operation returns a request object that emits success or error events. This can feel verbose compared to modern promise-based APIs, but we’ll address that later.
// Conceptual hierarchy
const database = {
name: 'MyAppDB',
version: 1,
objectStores: {
users: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
],
posts: [
{ id: 1, userId: 1, title: 'First Post', content: '...' }
]
}
};
Creating and Opening a Database
Opening a database triggers version management logic. If the database doesn’t exist or you specify a higher version number, the onupgradeneeded event fires—this is where you define your schema.
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MyAppDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store with auto-incrementing key
if (!db.objectStoreNames.contains('tasks')) {
const objectStore = db.createObjectStore('tasks', {
keyPath: 'id',
autoIncrement: true
});
// Create indexes for querying
objectStore.createIndex('status', 'status', { unique: false });
objectStore.createIndex('createdAt', 'createdAt', { unique: false });
objectStore.createIndex('priority', 'priority', { unique: false });
}
};
});
}
The keyPath option specifies which property serves as the primary key. With autoIncrement: true, IndexedDB generates keys automatically. Indexes enable efficient queries on non-key fields.
Version management is critical: you can only create or modify object stores during the onupgradeneeded event. To change your schema later, increment the version number.
CRUD Operations
All data operations happen within transactions. Transactions are scoped to specific object stores and have modes: readonly, readwrite, or versionchange.
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({
title: task.title,
status: 'pending',
priority: task.priority || 'medium',
createdAt: new Date().toISOString()
});
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
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);
});
}
async function updateTask(db, id, updates) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['tasks'], 'readwrite');
const store = transaction.objectStore('tasks');
// Get existing record first
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const task = getRequest.result;
Object.assign(task, updates);
const updateRequest = store.put(task);
updateRequest.onsuccess = () => resolve(updateRequest.result);
updateRequest.onerror = () => reject(updateRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
});
}
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);
});
}
The difference between add() and put(): add() fails if a record with that key already exists, while put() overwrites it. Use add() for new records and put() for upserts.
Querying with Indexes and Cursors
Indexes enable efficient queries without scanning every record. Cursors let you iterate through result sets with fine-grained control.
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('status');
const request = index.getAll(status);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function getTasksWithCursor(db, filterFn) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['tasks'], 'readonly');
const store = transaction.objectStore('tasks');
const results = [];
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (!filterFn || filterFn(cursor.value)) {
results.push(cursor.value);
}
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
async function getHighPriorityTasks(db) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['tasks'], 'readonly');
const store = transaction.objectStore('tasks');
const index = store.index('priority');
// Range query: only 'high' priority
const range = IDBKeyRange.only('high');
const request = index.getAll(range);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
IDBKeyRange supports only(), lowerBound(), upperBound(), and bound() for range queries. This is powerful for date ranges, numeric filters, and sorted results.
Practical Implementation: Todo App with Offline Support
Here’s a complete implementation wrapping IndexedDB in a cleaner API:
class TaskDatabase {
constructor() {
this.db = null;
this.dbName = 'TaskAppDB';
this.version = 1;
}
async init() {
this.db = await openDatabase();
return this;
}
async addTask(title, priority = 'medium') {
try {
const id = await addTask(this.db, { title, priority });
return { id, title, status: 'pending', priority };
} catch (error) {
console.error('Failed to add task:', error);
throw error;
}
}
async getAllTasks() {
return getTasksWithCursor(this.db);
}
async getTasksByStatus(status) {
return getTasksByStatus(this.db, status);
}
async completeTask(id) {
return updateTask(this.db, id, {
status: 'completed',
completedAt: new Date().toISOString()
});
}
async deleteTask(id) {
return deleteTask(this.db, id);
}
async clearCompleted() {
const completed = await this.getTasksByStatus('completed');
await Promise.all(
completed.map(task => this.deleteTask(task.id))
);
}
}
// Usage
(async () => {
const taskDB = await new TaskDatabase().init();
await taskDB.addTask('Learn IndexedDB', 'high');
await taskDB.addTask('Build offline app', 'medium');
const tasks = await taskDB.getAllTasks();
console.log('All tasks:', tasks);
await taskDB.completeTask(tasks[0].id);
const pending = await taskDB.getTasksByStatus('pending');
console.log('Pending tasks:', pending);
})();
Best Practices and Gotchas
Transaction lifecycle matters: Transactions auto-commit when all requests complete and no new requests are queued. Don’t perform async operations (like fetch()) inside transaction callbacks—the transaction will commit before your async work finishes.
Wrap in promises: The event-driven API is verbose. Create utility wrappers for cleaner async/await syntax:
function promisifyRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function getTaskSimple(db, id) {
const transaction = db.transaction(['tasks'], 'readonly');
const store = transaction.objectStore('tasks');
return promisifyRequest(store.get(id));
}
Error handling: Always handle errors. IndexedDB operations can fail due to quota limits, transaction conflicts, or corrupted data.
Browser compatibility: IndexedDB is well-supported in modern browsers, but implementations vary. Test thoroughly, especially around quota limits and private browsing modes.
When NOT to use IndexedDB: For simple key-value storage, localStorage is simpler. For large files, consider the File System Access API. For sensitive data, remember IndexedDB isn’t encrypted—anyone with file system access can read it.
Migration strategy: Use version numbers to handle schema changes. Keep old version handlers for users on outdated app versions:
request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
// Initial schema
db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
}
if (oldVersion < 2) {
// Add new index in version 2
const transaction = event.target.transaction;
const store = transaction.objectStore('tasks');
store.createIndex('category', 'category', { unique: false });
}
};
IndexedDB is powerful but complex. Use it when you need real database capabilities in the browser, and consider wrapper libraries like idb or Dexie.js for production applications to reduce boilerplate.