Browser APIs
JavaScript IndexedDB: Complete Guide to Client-Side Storage
Master IndexedDB for large-scale client-side storage in JavaScript. Learn database operations, transactions, and building offline-capable applications.
By JavaScriptDoc Team•
indexeddbstoragedatabaseofflinejavascript
JavaScript IndexedDB: Complete Guide to Client-Side Storage
IndexedDB is a powerful, low-level API for client-side storage of large amounts of structured data, including files and blobs. It provides a database-like storage mechanism for web applications.
Understanding IndexedDB
IndexedDB is a transactional database system that lets you store and retrieve objects indexed with keys.
// Basic IndexedDB concepts
// 1. Database - Contains object stores
// 2. Object Store - Like a table, stores records
// 3. Index - Provides alternate keys for accessing data
// 4. Transaction - Groups operations together
// 5. Cursor - Iterates over records
// Check for IndexedDB support
if ('indexedDB' in window) {
console.log('IndexedDB is supported');
} else {
console.log('IndexedDB is not supported');
}
// Open a database
const request = indexedDB.open('MyDatabase', 1);
request.onerror = function (event) {
console.error('Database error:', event.target.error);
};
request.onsuccess = function (event) {
const db = event.target.result;
console.log('Database opened successfully');
};
request.onupgradeneeded = function (event) {
const db = event.target.result;
// Create object store
if (!db.objectStoreNames.contains('customers')) {
const objectStore = db.createObjectStore('customers', { keyPath: 'id' });
// Create indexes
objectStore.createIndex('name', 'name', { unique: false });
objectStore.createIndex('email', 'email', { unique: true });
}
};
Database Operations
Creating and Managing Databases
class IndexedDBManager {
constructor(dbName, version) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
this.db = event.target.result;
this.setupDatabase(event);
};
});
}
setupDatabase(event) {
const db = event.target.result;
const oldVersion = event.oldVersion;
// Version 1: Create initial schema
if (oldVersion < 1) {
// Users store
const userStore = db.createObjectStore('users', {
keyPath: 'id',
autoIncrement: true,
});
userStore.createIndex('email', 'email', { unique: true });
userStore.createIndex('createdAt', 'createdAt', { unique: false });
// Products store
const productStore = db.createObjectStore('products', {
keyPath: 'sku',
});
productStore.createIndex('category', 'category', { unique: false });
productStore.createIndex('price', 'price', { unique: false });
productStore.createIndex('name', 'name', { unique: false });
// Orders store with compound index
const orderStore = db.createObjectStore('orders', {
keyPath: 'orderId',
autoIncrement: true,
});
orderStore.createIndex('userId', 'userId', { unique: false });
orderStore.createIndex('status', 'status', { unique: false });
orderStore.createIndex('userStatus', ['userId', 'status'], {
unique: false,
});
}
// Version 2: Add new indexes
if (oldVersion < 2) {
const userStore = event.target.transaction.objectStore('users');
userStore.createIndex('username', 'username', { unique: true });
}
}
async deleteDatabase() {
if (this.db) {
this.db.close();
}
return new Promise((resolve, reject) => {
const deleteReq = indexedDB.deleteDatabase(this.dbName);
deleteReq.onsuccess = () => resolve();
deleteReq.onerror = () => reject(deleteReq.error);
});
}
}
CRUD Operations
class IndexedDBStore {
constructor(db, storeName) {
this.db = db;
this.storeName = storeName;
}
// Create/Update
async put(data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Create (will fail if key exists)
async add(data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.add(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Read
async get(key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Read by index
async getByIndex(indexName, value) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index(indexName);
const request = index.get(value);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get all matching index
async getAllByIndex(indexName, value) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index(indexName);
const request = index.getAll(value);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Update
async update(key, updates) {
const existing = await this.get(key);
if (!existing) {
throw new Error('Record not found');
}
const updated = { ...existing, ...updates };
return this.put(updated);
}
// Delete
async delete(key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Clear all
async clear() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Count records
async count() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
// Usage
const dbManager = new IndexedDBManager('MyApp', 1);
const db = await dbManager.open();
const userStore = new IndexedDBStore(db, 'users');
// Add user
await userStore.add({
name: 'John Doe',
email: 'john@example.com',
createdAt: new Date(),
});
// Get user by ID
const user = await userStore.get(1);
// Get user by email
const userByEmail = await userStore.getByIndex('email', 'john@example.com');
// Update user
await userStore.update(1, { name: 'John Smith' });
// Delete user
await userStore.delete(1);
Cursors and Queries
Working with Cursors
class CursorQuery {
constructor(db, storeName) {
this.db = db;
this.storeName = storeName;
}
// Iterate all records
async iterate(callback, direction = 'next') {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.openCursor(null, direction);
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const shouldContinue = callback(cursor.value, cursor.key);
if (shouldContinue !== false) {
results.push(cursor.value);
cursor.continue();
}
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
// Query with range
async queryRange(options = {}) {
const {
index: indexName,
lower,
upper,
lowerOpen = false,
upperOpen = false,
direction = 'next',
limit,
} = options;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const source = indexName ? store.index(indexName) : store;
let range;
if (lower !== undefined && upper !== undefined) {
range = IDBKeyRange.bound(lower, upper, lowerOpen, upperOpen);
} else if (lower !== undefined) {
range = IDBKeyRange.lowerBound(lower, lowerOpen);
} else if (upper !== undefined) {
range = IDBKeyRange.upperBound(upper, upperOpen);
}
const request = source.openCursor(range, direction);
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor && (!limit || results.length < limit)) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
// Advanced filtering
async filter(predicate, options = {}) {
const { index, range } = options;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const source = index ? store.index(index) : store;
const request = source.openCursor(range);
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (predicate(cursor.value)) {
results.push(cursor.value);
}
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
// Pagination
async paginate(page = 1, pageSize = 20, options = {}) {
const { index, direction = 'next' } = options;
const offset = (page - 1) * pageSize;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const source = index ? store.index(index) : store;
const request = source.openCursor(null, direction);
let skipped = 0;
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (skipped < offset) {
skipped++;
cursor.continue();
} else if (results.length < pageSize) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
}
// Usage examples
const query = new CursorQuery(db, 'products');
// Get all products
const allProducts = await query.iterate((product) => {
console.log(product);
return true; // Continue iteration
});
// Get products in price range
const affordableProducts = await query.queryRange({
index: 'price',
lower: 10,
upper: 50,
limit: 10,
});
// Filter products
const electronics = await query.filter(
(product) => product.category === 'Electronics',
{ index: 'category' }
);
// Paginate results
const page1 = await query.paginate(1, 20, { index: 'name' });
Transactions
Transaction Management
class TransactionManager {
constructor(db) {
this.db = db;
}
// Execute multiple operations in transaction
async executeTransaction(storeNames, mode, operations) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeNames, mode);
const results = [];
transaction.oncomplete = () => resolve(results);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(new Error('Transaction aborted'));
// Execute operations
operations(transaction, results);
});
}
// Batch operations
async batchOperation(storeName, operations) {
return this.executeTransaction(
[storeName],
'readwrite',
(transaction, results) => {
const store = transaction.objectStore(storeName);
operations.forEach((op) => {
let request;
switch (op.type) {
case 'put':
request = store.put(op.data);
break;
case 'add':
request = store.add(op.data);
break;
case 'delete':
request = store.delete(op.key);
break;
default:
throw new Error(`Unknown operation: ${op.type}`);
}
request.onsuccess = () =>
results.push({
success: true,
operation: op.type,
result: request.result,
});
request.onerror = () =>
results.push({
success: false,
operation: op.type,
error: request.error,
});
});
}
);
}
// Atomic transfer between stores
async transfer(fromStore, toStore, key, transform) {
return this.executeTransaction(
[fromStore, toStore],
'readwrite',
(transaction) => {
const from = transaction.objectStore(fromStore);
const to = transaction.objectStore(toStore);
// Get from source
const getRequest = from.get(key);
getRequest.onsuccess = () => {
const data = getRequest.result;
if (!data) {
transaction.abort();
return;
}
// Transform if needed
const transformed = transform ? transform(data) : data;
// Add to destination
const addRequest = to.add(transformed);
addRequest.onsuccess = () => {
// Delete from source
from.delete(key);
};
};
}
);
}
}
// Usage
const txManager = new TransactionManager(db);
// Batch insert
await txManager.batchOperation('users', [
{ type: 'add', data: { name: 'User 1', email: 'user1@example.com' } },
{ type: 'add', data: { name: 'User 2', email: 'user2@example.com' } },
{ type: 'add', data: { name: 'User 3', email: 'user3@example.com' } },
]);
// Complex transaction
await txManager.executeTransaction(
['orders', 'inventory'],
'readwrite',
(transaction) => {
const orders = transaction.objectStore('orders');
const inventory = transaction.objectStore('inventory');
// Create order
orders.add({
userId: 1,
items: [{ sku: 'ABC123', quantity: 2 }],
status: 'pending',
});
// Update inventory
const getInventory = inventory.get('ABC123');
getInventory.onsuccess = () => {
const item = getInventory.result;
item.quantity -= 2;
inventory.put(item);
};
}
);
Complex Queries
Advanced Query Patterns
class QueryBuilder {
constructor(db) {
this.db = db;
}
// Join-like operations
async join(primaryStore, foreignStore, primaryKey, foreignKey) {
const transaction = this.db.transaction(
[primaryStore, foreignStore],
'readonly'
);
const primary = transaction.objectStore(primaryStore);
const foreign = transaction.objectStore(foreignStore);
return new Promise((resolve, reject) => {
const results = [];
const primaryRequest = primary.openCursor();
primaryRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const primaryRecord = cursor.value;
const foreignRequest = foreign.get(primaryRecord[foreignKey]);
foreignRequest.onsuccess = () => {
results.push({
...primaryRecord,
[foreignStore]: foreignRequest.result,
});
cursor.continue();
};
} else {
resolve(results);
}
};
primaryRequest.onerror = () => reject(primaryRequest.error);
});
}
// Aggregation
async aggregate(storeName, operations) {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const aggregates = {
count: 0,
sum: {},
avg: {},
min: {},
max: {},
values: {},
};
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const record = cursor.value;
aggregates.count++;
// Process each operation
operations.forEach((op) => {
const value = record[op.field];
switch (op.type) {
case 'sum':
aggregates.sum[op.field] =
(aggregates.sum[op.field] || 0) + value;
break;
case 'avg':
aggregates.values[op.field] = aggregates.values[op.field] || [];
aggregates.values[op.field].push(value);
break;
case 'min':
aggregates.min[op.field] = Math.min(
aggregates.min[op.field] ?? Infinity,
value
);
break;
case 'max':
aggregates.max[op.field] = Math.max(
aggregates.max[op.field] ?? -Infinity,
value
);
break;
}
});
cursor.continue();
} else {
// Calculate averages
Object.keys(aggregates.values).forEach((field) => {
const values = aggregates.values[field];
aggregates.avg[field] =
values.reduce((a, b) => a + b, 0) / values.length;
});
delete aggregates.values;
resolve(aggregates);
}
};
request.onerror = () => reject(request.error);
});
}
// Full-text search simulation
async search(storeName, searchFields, query) {
const keywords = query
.toLowerCase()
.split(' ')
.filter((k) => k.length > 2);
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const results = [];
const scores = new Map();
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const record = cursor.value;
let score = 0;
searchFields.forEach((field) => {
const fieldValue = String(record[field] || '').toLowerCase();
keywords.forEach((keyword) => {
if (fieldValue.includes(keyword)) {
score += fieldValue.split(keyword).length - 1;
}
});
});
if (score > 0) {
results.push(record);
scores.set(record, score);
}
cursor.continue();
} else {
// Sort by relevance
results.sort((a, b) => scores.get(b) - scores.get(a));
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
}
// Usage
const queryBuilder = new QueryBuilder(db);
// Join orders with users
const ordersWithUsers = await queryBuilder.join(
'orders',
'users',
'orderId',
'userId'
);
// Aggregate product statistics
const productStats = await queryBuilder.aggregate('products', [
{ type: 'sum', field: 'price' },
{ type: 'avg', field: 'price' },
{ type: 'min', field: 'price' },
{ type: 'max', field: 'price' },
]);
// Search products
const searchResults = await queryBuilder.search(
'products',
['name', 'description', 'category'],
'wireless headphones'
);
Performance Optimization
IndexedDB Performance Tips
class PerformanceOptimizer {
constructor(db) {
this.db = db;
}
// Bulk insert with batching
async bulkInsert(storeName, data, batchSize = 100) {
const batches = [];
for (let i = 0; i < data.length; i += batchSize) {
batches.push(data.slice(i, i + batchSize));
}
let inserted = 0;
for (const batch of batches) {
await new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
transaction.oncomplete = () => {
inserted += batch.length;
console.log(`Inserted ${inserted}/${data.length} records`);
resolve();
};
transaction.onerror = () => reject(transaction.error);
batch.forEach((item) => store.put(item));
});
}
return inserted;
}
// Lazy loading with virtual scrolling
createVirtualScroller(storeName, pageSize = 50) {
return {
cache: new Map(),
currentPage: 0,
async loadPage(page) {
if (this.cache.has(page)) {
return this.cache.get(page);
}
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const results = [];
let skipped = 0;
const offset = page * pageSize;
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (skipped < offset) {
skipped++;
cursor.advance(1);
} else if (results.length < pageSize) {
results.push(cursor.value);
cursor.continue();
} else {
this.cache.set(page, results);
resolve(results);
}
} else {
this.cache.set(page, results);
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
},
clearCache() {
this.cache.clear();
},
};
}
// Index usage optimizer
async analyzeIndexUsage(storeName) {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const analysis = {
storeName,
recordCount: 0,
indexes: {},
recommendations: [],
};
// Get record count
const countRequest = store.count();
await new Promise((resolve) => {
countRequest.onsuccess = () => {
analysis.recordCount = countRequest.result;
resolve();
};
});
// Analyze indexes
const indexNames = Array.from(store.indexNames);
for (const indexName of indexNames) {
const index = store.index(indexName);
const unique = index.unique;
const multiEntry = index.multiEntry;
analysis.indexes[indexName] = {
unique,
multiEntry,
keyPath: index.keyPath,
};
}
// Recommendations
if (analysis.recordCount > 1000 && indexNames.length === 0) {
analysis.recommendations.push(
'Consider adding indexes for frequently queried fields'
);
}
return analysis;
}
}
// Usage
const optimizer = new PerformanceOptimizer(db);
// Bulk insert
const largeDataset = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
value: Math.random() * 1000,
}));
await optimizer.bulkInsert('items', largeDataset);
// Virtual scrolling
const scroller = optimizer.createVirtualScroller('items');
const firstPage = await scroller.loadPage(0);
Sync and Backup
Data Synchronization
class SyncManager {
constructor(db, apiEndpoint) {
this.db = db;
this.apiEndpoint = apiEndpoint;
this.syncInProgress = false;
}
async sync(storeName) {
if (this.syncInProgress) {
console.log('Sync already in progress');
return;
}
this.syncInProgress = true;
try {
// Pull changes from server
await this.pull(storeName);
// Push local changes
await this.push(storeName);
console.log('Sync completed successfully');
} catch (error) {
console.error('Sync failed:', error);
throw error;
} finally {
this.syncInProgress = false;
}
}
async pull(storeName) {
// Get last sync timestamp
const lastSync = await this.getLastSync(storeName);
// Fetch changes from server
const response = await fetch(`${this.apiEndpoint}/${storeName}/changes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ since: lastSync }),
});
const changes = await response.json();
// Apply changes locally
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
for (const change of changes) {
switch (change.operation) {
case 'create':
case 'update':
store.put(change.data);
break;
case 'delete':
store.delete(change.key);
break;
}
}
// Update sync timestamp
await this.updateLastSync(storeName);
}
async push(storeName) {
// Get local changes
const changes = await this.getLocalChanges(storeName);
if (changes.length === 0) {
return;
}
// Send to server
const response = await fetch(`${this.apiEndpoint}/${storeName}/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes }),
});
if (response.ok) {
// Clear change tracking
await this.clearLocalChanges(storeName);
}
}
async backup() {
const backup = {
version: this.db.version,
timestamp: new Date().toISOString(),
stores: {},
};
const storeNames = Array.from(this.db.objectStoreNames);
for (const storeName of storeNames) {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
backup.stores[storeName] = await new Promise((resolve, reject) => {
const records = [];
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
records.push(cursor.value);
cursor.continue();
} else {
resolve(records);
}
};
request.onerror = () => reject(request.error);
});
}
return backup;
}
async restore(backup) {
// Clear existing data
const storeNames = Array.from(this.db.objectStoreNames);
for (const storeName of storeNames) {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
await new Promise((resolve) => {
const request = store.clear();
request.onsuccess = () => resolve();
});
}
// Restore data
for (const [storeName, records] of Object.entries(backup.stores)) {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
records.forEach((record) => store.put(record));
await new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
}
async getLastSync(storeName) {
const syncStore = await this.getSyncMetadata();
return syncStore[storeName]?.lastSync || null;
}
async updateLastSync(storeName) {
const syncStore = await this.getSyncMetadata();
syncStore[storeName] = {
...syncStore[storeName],
lastSync: new Date().toISOString(),
};
await this.saveSyncMetadata(syncStore);
}
async getSyncMetadata() {
// Implementation depends on where you store sync metadata
return JSON.parse(localStorage.getItem('syncMetadata') || '{}');
}
async saveSyncMetadata(metadata) {
localStorage.setItem('syncMetadata', JSON.stringify(metadata));
}
}
Best Practices
-
Handle Errors Gracefully
request.onerror = () => { console.error('IndexedDB error:', request.error); // Fallback to localStorage or other storage };
-
Version Management
// Always handle schema changes in onupgradeneeded request.onupgradeneeded = (event) => { // Migrate data if needed };
-
Transaction Scope
// Use minimal transaction scope const transaction = db.transaction(['store1'], 'readonly'); // Not: db.transaction(db.objectStoreNames, 'readwrite');
-
Index Strategy
// Create indexes for frequently queried fields // But don't over-index - each index increases storage
Conclusion
IndexedDB provides powerful client-side storage:
- Large storage capacity compared to other methods
- Structured data with indexes
- Transactional for data integrity
- Asynchronous to avoid blocking
- Complex queries with cursors
- Offline capabilities for PWAs
Key takeaways:
- Plan your schema carefully
- Use transactions appropriately
- Implement proper error handling
- Consider performance implications
- Test across browsers
- Implement sync strategies for offline apps
Master IndexedDB to build robust offline-capable web applications!