Progressive Web Apps (PWA)

IndexedDB for Offline Data

20 min Lesson 7 of 30

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:
  1. Create an IndexedDB database for a todo list application
  2. Implement CRUD operations for todo items
  3. Add an index to search todos by status (completed/pending)
  4. Implement data sync between IndexedDB and a mock API
  5. Handle storage quota exceeded errors