IndexedDB for Offline Data Storage
IndexedDB is a powerful browser database that allows you to store large amounts of structured data for offline use. Unlike localStorage, IndexedDB can store complex objects, handle large datasets, and supports indexes and transactions.
IndexedDB Basics
IndexedDB is an asynchronous, transactional database system that stores key-value pairs. It's ideal for PWAs that need to store significant amounts of data offline.
Key Concepts:
- Database: Container for object stores
- Object Store: Similar to a table in SQL databases
- Index: Allows querying by fields other than the primary key
- Transaction: Ensures data integrity during operations
- Cursor: Mechanism for iterating over multiple records
Opening a Database
The first step is to open (or create) a database:
// Open database
const dbName = 'myPWADatabase';
const dbVersion = 1;
const request = indexedDB.open(dbName, dbVersion);
request.onerror = (event) => {
console.error('Database error:', event.target.error);
};
request.onsuccess = (event) => {
const db = event.target.result;
console.log('Database opened successfully');
// Use db for operations
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains('users')) {
const objectStore = db.createObjectStore('users', {
keyPath: 'id',
autoIncrement: true
});
// Create indexes
objectStore.createIndex('email', 'email', { unique: true });
objectStore.createIndex('name', 'name', { unique: false });
console.log('Object store created');
}
};
CRUD Operations
Perform Create, Read, Update, and Delete operations using transactions:
// Helper function to get database
function getDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('myPWADatabase', 1);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// CREATE - Add a record
async function addUser(user) {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readwrite');
const objectStore = transaction.objectStore('users');
return new Promise((resolve, reject) => {
const request = objectStore.add(user);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// READ - Get a record by primary key
async function getUser(id) {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readonly');
const objectStore = transaction.objectStore('users');
return new Promise((resolve, reject) => {
const request = objectStore.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// READ - Get all records
async function getAllUsers() {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readonly');
const objectStore = transaction.objectStore('users');
return new Promise((resolve, reject) => {
const request = objectStore.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// UPDATE - Update a record
async function updateUser(user) {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readwrite');
const objectStore = transaction.objectStore('users');
return new Promise((resolve, reject) => {
const request = objectStore.put(user);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// DELETE - Delete a record
async function deleteUser(id) {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readwrite');
const objectStore = transaction.objectStore('users');
return new Promise((resolve, reject) => {
const request = objectStore.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Usage examples
async function demonstrateCRUD() {
try {
// Create
const userId = await addUser({
name: 'John Doe',
email: 'john@example.com',
age: 30
});
console.log('User added with ID:', userId);
// Read
const user = await getUser(userId);
console.log('User retrieved:', user);
// Update
user.age = 31;
await updateUser(user);
console.log('User updated');
// Delete
await deleteUser(userId);
console.log('User deleted');
} catch (error) {
console.error('CRUD operation failed:', error);
}
}
Using Indexes for Queries
Indexes allow you to query data by fields other than the primary key:
// Query by email using index
async function getUserByEmail(email) {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readonly');
const objectStore = transaction.objectStore('users');
const index = objectStore.index('email');
return new Promise((resolve, reject) => {
const request = index.get(email);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get all users with a specific name
async function getUsersByName(name) {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readonly');
const objectStore = transaction.objectStore('users');
const index = objectStore.index('name');
return new Promise((resolve, reject) => {
const request = index.getAll(name);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Working with Cursors
Cursors allow you to iterate through records efficiently:
async function iterateUsers() {
const db = await getDatabase();
const transaction = db.transaction(['users'], 'readonly');
const objectStore = transaction.objectStore('users');
return new Promise((resolve, reject) => {
const users = [];
const request = objectStore.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
users.push(cursor.value);
console.log('User:', cursor.value);
cursor.continue(); // Move to next record
} else {
// No more records
resolve(users);
}
};
request.onerror = () => reject(request.error);
});
}
When to Use Cursors:
- Iterating through large datasets
- Filtering records with custom logic
- Processing records one by one
- Implementing pagination
IndexedDB Wrapper Libraries
While native IndexedDB is powerful, it can be verbose. Consider using wrapper libraries:
// Using idb library (https://github.com/jakearchibald/idb)
import { openDB } from 'idb';
const db = await openDB('myPWADatabase', 1, {
upgrade(db) {
const store = db.createObjectStore('users', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('email', 'email', { unique: true });
}
});
// CRUD operations become much simpler
await db.add('users', { name: 'John', email: 'john@example.com' });
const user = await db.get('users', 1);
await db.put('users', { id: 1, name: 'Jane', email: 'jane@example.com' });
await db.delete('users', 1);
const allUsers = await db.getAll('users');
Offline Data Synchronization
Implement a sync strategy to keep local and server data in sync:
class DataSync {
constructor(apiUrl, storeName) {
this.apiUrl = apiUrl;
this.storeName = storeName;
}
async syncFromServer() {
try {
// Fetch data from server
const response = await fetch(this.apiUrl);
const serverData = await response.json();
// Store in IndexedDB
const db = await getDatabase();
const transaction = db.transaction([this.storeName], 'readwrite');
const objectStore = transaction.objectStore(this.storeName);
// Clear existing data
await objectStore.clear();
// Add server data
for (const item of serverData) {
await objectStore.add(item);
}
console.log('Data synced from server');
return true;
} catch (error) {
console.error('Sync failed:', error);
return false;
}
}
async syncToServer() {
try {
// Get local changes
const db = await getDatabase();
const transaction = db.transaction([this.storeName], 'readonly');
const objectStore = transaction.objectStore(this.storeName);
const localData = await objectStore.getAll();
// Send to server
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(localData)
});
if (response.ok) {
console.log('Local data synced to server');
return true;
}
} catch (error) {
console.error('Sync to server failed:', error);
return false;
}
}
async autoSync() {
if (navigator.onLine) {
await this.syncFromServer();
await this.syncToServer();
}
}
}
// Usage
const userSync = new DataSync('/api/users', 'users');
// Sync when online
window.addEventListener('online', () => {
userSync.autoSync();
});
// Initial sync
if (navigator.onLine) {
userSync.syncFromServer();
}
Storage Limits: IndexedDB has storage limits that vary by browser. Always handle quota exceeded errors gracefully and inform users when storage is running low.
Checking Storage Quota
async function checkStorageQuota() {
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const percentUsed = (estimate.usage / estimate.quota) * 100;
console.log(`Storage used: ${estimate.usage} bytes`);
console.log(`Storage quota: ${estimate.quota} bytes`);
console.log(`Percent used: ${percentUsed.toFixed(2)}%`);
if (percentUsed > 80) {
console.warn('Storage is running low!');
}
}
}
Exercise:
- Create an IndexedDB database for a todo list application
- Implement CRUD operations for todo items
- Add an index to search todos by status (completed/pending)
- Implement data sync between IndexedDB and a mock API
- Handle storage quota exceeded errors