24 min read

Mastering IndexedDB: A Complete Guide to Browser Database Storage

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 which can only store strings up to 5-10MB, IndexedDB can handle large volumes of data (hundreds of megabytes or even gigabytes depending on browser implementation) and provides indexed database functionality directly in the browser.

Why Choose IndexedDB?

Modern web applications demand robust client-side storage solutions. IndexedDB stands out for several compelling reasons:

  • Large Storage Capacity: Store hundreds of megabytes to gigabytes of data
  • Structured Data Support: Handle complex JavaScript objects, not just strings
  • Asynchronous Operations: Non-blocking API that keeps your UI responsive
  • Transactional Database: ACID-compliant transactions ensure data integrity
  • Indexed Queries: Fast data retrieval through multiple indexes
  • Offline Capabilities: Build Progressive Web Apps (PWAs) that work without internet

Understanding IndexedDB Architecture

Before diving into code, let’s understand how IndexedDB is structured:

Core Concepts

  1. Database: The top-level container for your data
  2. Object Stores: Similar to tables in SQL databases, they hold your data
  3. Indexes: Optional structures that enable fast lookups on specific properties
  4. Transactions: Wrapper for database operations ensuring data consistency
  5. Keys: Unique identifiers for each record (can be auto-generated or manual)
  6. Values: The actual data stored (JavaScript objects, arrays, primitives, etc.)
Database
  ├── Object Store 1
  │     ├── Index 1
  │     ├── Index 2
  │     └── Records (key-value pairs)
  └── Object Store 2
        ├── Index 1
        └── Records (key-value pairs)

Setting Up Your First IndexedDB Database

Let’s create a practical example - a task management database that stores user tasks with categories and priorities.

Step 1: Opening a Database

// Database configuration
const DB_NAME = 'TaskManagerDB';
const DB_VERSION = 1;
const STORE_NAME = 'tasks';

function openDatabase() {
  return new Promise((resolve, reject) => {
    // Request to open database
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    // Error handler
    request.onerror = () => {
      reject(`Database error: ${request.error}`);
    };

    // Success handler
    request.onsuccess = () => {
      const db = request.result;
      console.log('Database opened successfully');
      resolve(db);
    };

    // Database upgrade handler - runs when creating or upgrading
    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      // Create object store if it doesn't exist
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        // Create store with auto-incrementing key
        const objectStore = db.createObjectStore(STORE_NAME, {
          keyPath: 'id',
          autoIncrement: true,
        });

        // Create indexes for fast querying
        objectStore.createIndex('title', 'title', { unique: false });
        objectStore.createIndex('category', 'category', { unique: false });
        objectStore.createIndex('priority', 'priority', { unique: false });
        objectStore.createIndex('completed', 'completed', { unique: false });
        objectStore.createIndex('dueDate', 'dueDate', { unique: false });

        console.log('Object store created successfully');
      }
    };
  });
}

Key Points Explained:

  • indexedDB.open(name, version): Opens a database, creating it if it doesn’t exist
  • keyPath: 'id': Specifies which property serves as the primary key
  • autoIncrement: true: Database automatically generates sequential IDs
  • onupgradeneeded: Fires only when database version changes or is created initially
  • Indexes enable fast queries on non-key properties (like searching by category)

Step 2: Adding Data to IndexedDB

async function addTask(taskData) {
  try {
    const db = await openDatabase();

    // Create a transaction
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const objectStore = transaction.objectStore(STORE_NAME);

    // Prepare task object
    const task = {
      title: taskData.title,
      description: taskData.description,
      category: taskData.category,
      priority: taskData.priority,
      completed: false,
      createdAt: new Date().toISOString(),
      dueDate: taskData.dueDate || null,
    };

    // Add task to store
    const request = objectStore.add(task);

    return new Promise((resolve, reject) => {
      request.onsuccess = () => {
        console.log('Task added with ID:', request.result);
        resolve(request.result);
      };

      request.onerror = () => {
        reject(`Error adding task: ${request.error}`);
      };

      // Transaction completion
      transaction.oncomplete = () => {
        db.close();
      };
    });
  } catch (error) {
    console.error('Failed to add task:', error);
    throw error;
  }
}

// Usage example
addTask({
  title: 'Complete IndexedDB blog post',
  description: 'Write comprehensive guide with examples',
  category: 'Writing',
  priority: 'high',
  dueDate: '2025-12-10',
}).then((id) => {
  console.log(`Task created with ID: ${id}`);
});

Transaction Modes:

  • readonly: Default mode for read operations
  • readwrite: Required for add, put, delete operations
  • versionchange: Used internally during upgrades

Step 3: Reading Data from IndexedDB

// Get single task by ID
async function getTaskById(id) {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const objectStore = transaction.objectStore(STORE_NAME);
    const request = objectStore.get(id);

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = () => {
      reject(`Error fetching task: ${request.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// Get all tasks
async function getAllTasks() {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const objectStore = transaction.objectStore(STORE_NAME);
    const request = objectStore.getAll();

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = () => {
      reject(`Error fetching tasks: ${request.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// Get tasks by category using index
async function getTasksByCategory(category) {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const objectStore = transaction.objectStore(STORE_NAME);
    const index = objectStore.index('category');
    const request = index.getAll(category);

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = () => {
      reject(`Error fetching tasks: ${request.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// Usage examples
getAllTasks().then((tasks) => {
  console.log('All tasks:', tasks);
});

getTasksByCategory('Writing').then((tasks) => {
  console.log('Writing tasks:', tasks);
});

Step 4: Updating Data

async function updateTask(id, updates) {
  const db = await openDatabase();

  return new Promise(async (resolve, reject) => {
    try {
      const transaction = db.transaction([STORE_NAME], 'readwrite');
      const objectStore = transaction.objectStore(STORE_NAME);

      // First, get the existing task
      const getRequest = objectStore.get(id);

      getRequest.onsuccess = () => {
        const task = getRequest.result;

        if (!task) {
          reject(`Task with ID ${id} not found`);
          return;
        }

        // Merge updates with existing data
        const updatedTask = {
          ...task,
          ...updates,
          updatedAt: new Date().toISOString(),
        };

        // Put updated task back
        const putRequest = objectStore.put(updatedTask);

        putRequest.onsuccess = () => {
          console.log('Task updated successfully');
          resolve(updatedTask);
        };

        putRequest.onerror = () => {
          reject(`Error updating task: ${putRequest.error}`);
        };
      };

      getRequest.onerror = () => {
        reject(`Error fetching task: ${getRequest.error}`);
      };

      transaction.oncomplete = () => {
        db.close();
      };
    } catch (error) {
      reject(error);
    }
  });
}

// Mark task as completed
updateTask(1, { completed: true }).then((task) => {
  console.log('Task marked as completed:', task);
});

// Update task priority
updateTask(2, {
  priority: 'urgent',
  dueDate: '2025-12-06',
}).then((task) => {
  console.log('Task priority updated:', task);
});

Note: The put() method will update existing records or create new ones if the key doesn’t exist. The add() method only creates new records and fails if the key exists.

Step 5: Deleting Data

async function deleteTask(id) {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const objectStore = transaction.objectStore(STORE_NAME);
    const request = objectStore.delete(id);

    request.onsuccess = () => {
      console.log(`Task ${id} deleted successfully`);
      resolve(true);
    };

    request.onerror = () => {
      reject(`Error deleting task: ${request.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// Delete all completed tasks
async function deleteCompletedTasks() {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const objectStore = transaction.objectStore(STORE_NAME);
    const index = objectStore.index('completed');
    const request = index.openCursor(IDBKeyRange.only(true));

    let deletedCount = 0;

    request.onsuccess = (event) => {
      const cursor = event.target.result;

      if (cursor) {
        cursor.delete();
        deletedCount++;
        cursor.continue();
      } else {
        console.log(`Deleted ${deletedCount} completed tasks`);
        resolve(deletedCount);
      }
    };

    request.onerror = () => {
      reject(`Error deleting tasks: ${request.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// Usage
deleteTask(5);
deleteCompletedTasks().then((count) => {
  console.log(`${count} tasks removed`);
});

Advanced IndexedDB Techniques

Using Cursors for Complex Queries

Cursors allow you to iterate through records and perform complex filtering:

async function getHighPriorityIncompleteTasks() {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const objectStore = transaction.objectStore(STORE_NAME);
    const index = objectStore.index('priority');

    // Open cursor for high priority tasks
    const request = index.openCursor(IDBKeyRange.only('high'));
    const results = [];

    request.onsuccess = (event) => {
      const cursor = event.target.result;

      if (cursor) {
        const task = cursor.value;

        // Filter for incomplete tasks
        if (!task.completed) {
          results.push(task);
        }

        // Continue to next record
        cursor.continue();
      } else {
        // No more records
        resolve(results);
      }
    };

    request.onerror = () => {
      reject(`Cursor error: ${request.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

Key Ranges for Filtered Queries

IDBKeyRange provides powerful filtering capabilities:

async function getTasksByDateRange(startDate, endDate) {
  const db = await openDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const objectStore = transaction.objectStore(STORE_NAME);
    const index = objectStore.index('dueDate');

    // Create a range between two dates
    const range = IDBKeyRange.bound(startDate, endDate);
    const request = index.getAll(range);

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = () => {
      reject(`Error fetching tasks: ${request.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// Key Range examples
IDBKeyRange.only('high'); // Exactly 'high'
IDBKeyRange.lowerBound('2025-12-01'); // >= '2025-12-01'
IDBKeyRange.upperBound('2025-12-31'); // <= '2025-12-31'
IDBKeyRange.bound('2025-12-01', '2025-12-31'); // Between dates

// Exclusive bounds
IDBKeyRange.lowerBound('A', true); // > 'A' (excludes 'A')
IDBKeyRange.bound(1, 10, false, true); // 1 <= x < 10

Handling Database Versioning

When you need to modify your database structure:

const DB_VERSION = 2; // Increment version

function openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      const oldVersion = event.oldVersion;

      // Version 1: Initial setup
      if (oldVersion < 1) {
        const objectStore = db.createObjectStore(STORE_NAME, {
          keyPath: 'id',
          autoIncrement: true,
        });
        objectStore.createIndex('title', 'title', { unique: false });
        objectStore.createIndex('category', 'category', { unique: false });
      }

      // Version 2: Add new indexes
      if (oldVersion < 2) {
        const transaction = event.target.transaction;
        const objectStore = transaction.objectStore(STORE_NAME);

        // Add new indexes
        objectStore.createIndex('tags', 'tags', {
          unique: false,
          multiEntry: true, // For array values
        });
        objectStore.createIndex('assignedTo', 'assignedTo', {
          unique: false,
        });

        console.log('Database upgraded to version 2');
      }
    };

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = () => {
      reject(`Database error: ${request.error}`);
    };
  });
}

Building a Complete IndexedDB Wrapper Class

Let’s create a reusable class that simplifies IndexedDB operations:

class IndexedDBManager {
  constructor(dbName, version, stores) {
    this.dbName = dbName;
    this.version = version;
    this.stores = stores; // Array of store configurations
    this.db = null;
  }

  async init() {
    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) => {
        const db = event.target.result;

        this.stores.forEach((storeConfig) => {
          if (!db.objectStoreNames.contains(storeConfig.name)) {
            const store = db.createObjectStore(storeConfig.name, storeConfig.options);

            // Create indexes
            if (storeConfig.indexes) {
              storeConfig.indexes.forEach((index) => {
                store.createIndex(index.name, index.keyPath, index.options);
              });
            }
          }
        });
      };
    });
  }

  async add(storeName, data) {
    const tx = this.db.transaction([storeName], 'readwrite');
    const store = tx.objectStore(storeName);

    return new Promise((resolve, reject) => {
      const request = store.add(data);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async get(storeName, key) {
    const tx = this.db.transaction([storeName], 'readonly');
    const store = tx.objectStore(storeName);

    return new Promise((resolve, reject) => {
      const request = store.get(key);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async getAll(storeName) {
    const tx = this.db.transaction([storeName], 'readonly');
    const store = tx.objectStore(storeName);

    return new Promise((resolve, reject) => {
      const request = store.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async update(storeName, data) {
    const tx = this.db.transaction([storeName], 'readwrite');
    const store = tx.objectStore(storeName);

    return new Promise((resolve, reject) => {
      const request = store.put(data);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async delete(storeName, key) {
    const tx = this.db.transaction([storeName], 'readwrite');
    const store = tx.objectStore(storeName);

    return new Promise((resolve, reject) => {
      const request = store.delete(key);
      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(request.error);
    });
  }

  async getByIndex(storeName, indexName, value) {
    const tx = this.db.transaction([storeName], 'readonly');
    const store = tx.objectStore(storeName);
    const index = store.index(indexName);

    return new Promise((resolve, reject) => {
      const request = index.getAll(value);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async query(storeName, filterFn) {
    const allData = await this.getAll(storeName);
    return allData.filter(filterFn);
  }

  async clear(storeName) {
    const tx = this.db.transaction([storeName], 'readwrite');
    const store = tx.objectStore(storeName);

    return new Promise((resolve, reject) => {
      const request = store.clear();
      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(request.error);
    });
  }

  close() {
    if (this.db) {
      this.db.close();
      this.db = null;
    }
  }
}

// Usage example
const dbManager = new IndexedDBManager('TaskManagerDB', 1, [
  {
    name: 'tasks',
    options: { keyPath: 'id', autoIncrement: true },
    indexes: [
      { name: 'title', keyPath: 'title', options: { unique: false } },
      { name: 'category', keyPath: 'category', options: { unique: false } },
      { name: 'priority', keyPath: 'priority', options: { unique: false } },
    ],
  },
]);

// Initialize and use
await dbManager.init();

// Add task
const taskId = await dbManager.add('tasks', {
  title: 'Learn IndexedDB',
  category: 'Education',
  priority: 'high',
});

// Get all tasks
const tasks = await dbManager.getAll('tasks');

// Get by index
const highPriorityTasks = await dbManager.getByIndex('tasks', 'priority', 'high');

// Custom query
const urgentIncompleteTasks = await dbManager.query('tasks', (task) => task.priority === 'urgent' && !task.completed);

Real-World Use Cases

1. Progressive Web App (PWA) Data Sync

class OfflineDataManager {
  constructor() {
    this.dbName = 'PWADataStore';
    this.version = 1;
  }

  async init() {
    const db = await this.openDB();
    return db;
  }

  async openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);

      request.onupgradeneeded = (event) => {
        const db = event.target.result;

        // Store for pending sync operations
        if (!db.objectStoreNames.contains('syncQueue')) {
          const store = db.createObjectStore('syncQueue', {
            keyPath: 'id',
            autoIncrement: true,
          });
          store.createIndex('timestamp', 'timestamp', { unique: false });
          store.createIndex('synced', 'synced', { unique: false });
        }

        // Store for cached data
        if (!db.objectStoreNames.contains('cache')) {
          const store = db.createObjectStore('cache', { keyPath: 'url' });
          store.createIndex('expiry', 'expiry', { unique: false });
        }
      };

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async addToSyncQueue(operation) {
    const db = await this.init();
    const tx = db.transaction(['syncQueue'], 'readwrite');
    const store = tx.objectStore('syncQueue');

    const syncItem = {
      ...operation,
      timestamp: Date.now(),
      synced: false,
    };

    return new Promise((resolve, reject) => {
      const request = store.add(syncItem);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async getPendingSync() {
    const db = await this.init();
    const tx = db.transaction(['syncQueue'], 'readonly');
    const store = tx.objectStore('syncQueue');
    const index = store.index('synced');

    return new Promise((resolve, reject) => {
      const request = index.getAll(false);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async markAsSynced(id) {
    const db = await this.init();
    const tx = db.transaction(['syncQueue'], 'readwrite');
    const store = tx.objectStore('syncQueue');

    const getRequest = store.get(id);

    return new Promise((resolve, reject) => {
      getRequest.onsuccess = () => {
        const item = getRequest.result;
        if (item) {
          item.synced = true;
          item.syncedAt = Date.now();
          const putRequest = store.put(item);
          putRequest.onsuccess = () => resolve(true);
          putRequest.onerror = () => reject(putRequest.error);
        } else {
          reject('Item not found');
        }
      };
      getRequest.onerror = () => reject(getRequest.error);
    });
  }

  async cacheData(url, data, ttl = 3600000) {
    // 1 hour default
    const db = await this.init();
    const tx = db.transaction(['cache'], 'readwrite');
    const store = tx.objectStore('cache');

    const cacheItem = {
      url,
      data,
      expiry: Date.now() + ttl,
      cachedAt: Date.now(),
    };

    return new Promise((resolve, reject) => {
      const request = store.put(cacheItem);
      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(request.error);
    });
  }

  async getCachedData(url) {
    const db = await this.init();
    const tx = db.transaction(['cache'], 'readonly');
    const store = tx.objectStore('cache');

    return new Promise((resolve, reject) => {
      const request = store.get(url);
      request.onsuccess = () => {
        const item = request.result;
        if (item && item.expiry > Date.now()) {
          resolve(item.data);
        } else {
          resolve(null); // Expired or not found
        }
      };
      request.onerror = () => reject(request.error);
    });
  }
}

// Usage in a PWA
const offlineManager = new OfflineDataManager();

// When online
async function syncData() {
  const pending = await offlineManager.getPendingSync();

  for (const item of pending) {
    try {
      // Sync with server
      await fetch('/api/sync', {
        method: 'POST',
        body: JSON.stringify(item),
      });

      await offlineManager.markAsSynced(item.id);
    } catch (error) {
      console.error('Sync failed:', error);
    }
  }
}

// When offline
async function saveOffline(data) {
  await offlineManager.addToSyncQueue({
    type: 'CREATE',
    endpoint: '/api/tasks',
    data: data,
  });
}

2. Storing and Retrieving Files

async function storeFile(file) {
  const db = await openDatabase();

  // Read file as ArrayBuffer
  const arrayBuffer = await file.arrayBuffer();

  const fileData = {
    name: file.name,
    type: file.type,
    size: file.size,
    lastModified: file.lastModified,
    data: arrayBuffer,
    uploadedAt: Date.now(),
  };

  const tx = db.transaction(['files'], 'readwrite');
  const store = tx.objectStore('files');

  return new Promise((resolve, reject) => {
    const request = store.add(fileData);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function retrieveFile(id) {
  const db = await openDatabase();
  const tx = db.transaction(['files'], 'readonly');
  const store = tx.objectStore('files');

  return new Promise((resolve, reject) => {
    const request = store.get(id);
    request.onsuccess = () => {
      const fileData = request.result;
      if (fileData) {
        // Convert ArrayBuffer back to Blob
        const blob = new Blob([fileData.data], { type: fileData.type });
        const file = new File([blob], fileData.name, {
          type: fileData.type,
          lastModified: fileData.lastModified,
        });
        resolve(file);
      } else {
        resolve(null);
      }
    };
    request.onerror = () => reject(request.error);
  });
}

// Usage
const input = document.querySelector('input[type="file"]');
input.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const id = await storeFile(file);
  console.log(`File stored with ID: ${id}`);

  // Later, retrieve it
  const retrievedFile = await retrieveFile(id);
  console.log('Retrieved file:', retrievedFile);
});

Performance Best Practices

1. Batch Operations in Transactions

async function addMultipleTasks(tasks) {
  const db = await openDatabase();
  const tx = db.transaction(['tasks'], 'readwrite');
  const store = tx.objectStore('tasks');

  // Add all tasks in a single transaction
  const promises = tasks.map((task) => {
    return new Promise((resolve, reject) => {
      const request = store.add(task);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  });

  try {
    const results = await Promise.all(promises);
    return results;
  } catch (error) {
    console.error('Batch operation failed:', error);
    throw error;
  }
}

// Add 100 tasks efficiently
const tasks = Array.from({ length: 100 }, (_, i) => ({
  title: `Task ${i + 1}`,
  completed: false,
  priority: ['low', 'medium', 'high'][i % 3],
}));

await addMultipleTasks(tasks);

2. Use Indexes Wisely

// Good: Using index for frequent queries
async function getTasksByStatus(completed) {
  const db = await openDatabase();
  const tx = db.transaction(['tasks'], 'readonly');
  const store = tx.objectStore('tasks');
  const index = store.index('completed'); // Fast!

  return new Promise((resolve, reject) => {
    const request = index.getAll(completed);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Bad: Fetching all and filtering
async function getTasksByStatusBad(completed) {
  const db = await openDatabase();
  const tx = db.transaction(['tasks'], 'readonly');
  const store = tx.objectStore('tasks');

  return new Promise((resolve, reject) => {
    const request = store.getAll();
    request.onsuccess = () => {
      // Inefficient filtering after fetching all data
      const filtered = request.result.filter((t) => t.completed === completed);
      resolve(filtered);
    };
    request.onerror = () => reject(request.error);
  });
}

3. Close Database Connections

class DatabaseConnection {
  constructor() {
    this.db = null;
    this.openPromise = null;
  }

  async getDB() {
    if (this.db) return this.db;

    if (!this.openPromise) {
      this.openPromise = this.open();
    }

    return this.openPromise;
  }

  async open() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('MyDB', 1);
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this.db);
      };
      request.onerror = () => reject(request.error);
    });
  }

  close() {
    if (this.db) {
      this.db.close();
      this.db = null;
      this.openPromise = null;
    }
  }
}

// Singleton pattern
const dbConnection = new DatabaseConnection();

// Use throughout your app
async function performOperation() {
  const db = await dbConnection.getDB();
  // Use db...
}

// Close when app closes
window.addEventListener('beforeunload', () => {
  dbConnection.close();
});

Error Handling and Debugging

Comprehensive Error Handling

class IndexedDBError extends Error {
  constructor(message, operation, originalError) {
    super(message);
    this.name = 'IndexedDBError';
    this.operation = operation;
    this.originalError = originalError;
  }
}

async function safeDBOperation(operation, operationName) {
  try {
    return await operation();
  } catch (error) {
    console.error(`IndexedDB ${operationName} failed:`, error);

    // Log to error tracking service
    if (window.trackError) {
      window.trackError(new IndexedDBError(`Failed to ${operationName}`, operationName, error));
    }

    throw error;
  }
}

// Usage
const tasks = await safeDBOperation(() => getAllTasks(), 'fetch all tasks');

Debugging Tips

// Enable verbose logging
function enableIndexedDBLogging() {
  const originalOpen = indexedDB.open;

  indexedDB.open = function (...args) {
    console.log('IndexedDB.open called:', args);
    const request = originalOpen.apply(this, args);

    request.onsuccess = function (event) {
      console.log('Database opened successfully:', event.target.result.name);
    };

    request.onerror = function (event) {
      console.error('Database open failed:', event.target.error);
    };

    return request;
  };
}

// Check available storage
async function checkStorageQuota() {
  if (navigator.storage && navigator.storage.estimate) {
    const estimate = await navigator.storage.estimate();
    const usageInMB = (estimate.usage / (1024 * 1024)).toFixed(2);
    const quotaInMB = (estimate.quota / (1024 * 1024)).toFixed(2);

    console.log(`Storage used: ${usageInMB} MB`);
    console.log(`Storage quota: ${quotaInMB} MB`);
    console.log(`Percentage used: ${((estimate.usage / estimate.quota) * 100).toFixed(2)}%`);

    return estimate;
  }
}

checkStorageQuota();

Browser Compatibility and Fallbacks

function checkIndexedDBSupport() {
  if (!window.indexedDB) {
    console.warn('IndexedDB not supported');
    return false;
  }

  // Check for specific features
  const features = {
    basic: !!window.indexedDB,
    cursors: 'openCursor' in IDBObjectStore.prototype,
    keyRange: !!window.IDBKeyRange,
    transaction: !!window.IDBTransaction,
  };

  console.log('IndexedDB features:', features);
  return features.basic;
}

// Fallback to localStorage
class StorageAdapter {
  constructor() {
    this.useIndexedDB = checkIndexedDBSupport();
  }

  async save(key, value) {
    if (this.useIndexedDB) {
      // Use IndexedDB
      return await this.saveToIndexedDB(key, value);
    } else {
      // Fallback to localStorage
      try {
        localStorage.setItem(key, JSON.stringify(value));
        return true;
      } catch (error) {
        console.error('Storage failed:', error);
        return false;
      }
    }
  }

  async get(key) {
    if (this.useIndexedDB) {
      return await this.getFromIndexedDB(key);
    } else {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : null;
    }
  }

  async saveToIndexedDB(key, value) {
    // IndexedDB implementation
  }

  async getFromIndexedDB(key) {
    // IndexedDB implementation
  }
}

Security Considerations

1. Data Encryption

// Simple encryption example (use a proper library in production)
async function encryptData(data, password) {
  const encoder = new TextEncoder();
  const dataBuffer = encoder.encode(JSON.stringify(data));
  const passwordBuffer = encoder.encode(password);

  const key = await crypto.subtle.importKey('raw', passwordBuffer, { name: 'PBKDF2' }, false, [
    'deriveBits',
    'deriveKey',
  ]);

  const aesKey = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: encoder.encode('salt-value'),
      iterations: 100000,
      hash: 'SHA-256',
    },
    key,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );

  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, dataBuffer);

  return { encrypted, iv };
}

async function storeEncryptedData(data, password) {
  const { encrypted, iv } = await encryptData(data, password);

  const db = await openDatabase();
  const tx = db.transaction(['secureData'], 'readwrite');
  const store = tx.objectStore('secureData');

  return new Promise((resolve, reject) => {
    const request = store.add({
      data: encrypted,
      iv: iv,
      timestamp: Date.now(),
    });
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

2. Data Validation

function validateTaskData(task) {
  const schema = {
    title: { type: 'string', required: true, maxLength: 200 },
    description: { type: 'string', required: false, maxLength: 1000 },
    category: { type: 'string', required: true },
    priority: {
      type: 'string',
      required: true,
      enum: ['low', 'medium', 'high', 'urgent'],
    },
    completed: { type: 'boolean', required: true },
  };

  for (const [field, rules] of Object.entries(schema)) {
    if (rules.required && !task[field]) {
      throw new Error(`Field ${field} is required`);
    }

    if (task[field] !== undefined) {
      if (rules.type && typeof task[field] !== rules.type) {
        throw new Error(`Field ${field} must be ${rules.type}`);
      }

      if (rules.maxLength && task[field].length > rules.maxLength) {
        throw new Error(`Field ${field} exceeds maximum length`);
      }

      if (rules.enum && !rules.enum.includes(task[field])) {
        throw new Error(`Field ${field} must be one of: ${rules.enum.join(', ')}`);
      }
    }
  }

  return true;
}

async function addValidatedTask(taskData) {
  try {
    validateTaskData(taskData);
    return await addTask(taskData);
  } catch (error) {
    console.error('Validation failed:', error);
    throw error;
  }
}

Testing IndexedDB

// Test utilities
class IndexedDBTestHelper {
  static async clearDatabase(dbName) {
    return new Promise((resolve, reject) => {
      const request = indexedDB.deleteDatabase(dbName);
      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(request.error);
    });
  }

  static async seedTestData(dbName, storeName, data) {
    const db = await this.openDB(dbName);
    const tx = db.transaction([storeName], 'readwrite');
    const store = tx.objectStore(storeName);

    for (const item of data) {
      store.add(item);
    }

    return new Promise((resolve, reject) => {
      tx.oncomplete = () => {
        db.close();
        resolve(true);
      };
      tx.onerror = () => reject(tx.error);
    });
  }

  static async openDB(dbName) {
    // Implementation...
  }
}

// Example test
async function testTaskOperations() {
  const dbName = 'TestDB';

  // Setup
  await IndexedDBTestHelper.clearDatabase(dbName);

  // Test adding
  const taskId = await addTask({
    title: 'Test Task',
    priority: 'high',
    completed: false,
  });

  console.assert(typeof taskId === 'number', 'Task ID should be number');

  // Test retrieval
  const task = await getTaskById(taskId);
  console.assert(task.title === 'Test Task', 'Task title should match');

  // Test update
  await updateTask(taskId, { completed: true });
  const updatedTask = await getTaskById(taskId);
  console.assert(updatedTask.completed === true, 'Task should be completed');

  // Test delete
  await deleteTask(taskId);
  const deletedTask = await getTaskById(taskId);
  console.assert(deletedTask === undefined, 'Task should be deleted');

  // Cleanup
  await IndexedDBTestHelper.clearDatabase(dbName);

  console.log('All tests passed!');
}

Common Pitfalls and Solutions

1. Blocked Database Errors

Problem: Database upgrade blocked by open connections.

function openDatabaseSafely(dbName, version) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(dbName, version);

    request.onblocked = () => {
      console.warn('Database upgrade blocked. Close other tabs.');
      // Notify user to close other tabs
      alert('Please close other tabs to update the database');
    };

    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      // Handle version change event in other connections
      db.onversionchange = () => {
        db.close();
        alert('Database is being upgraded. Please reload.');
      };
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

2. Transaction Lifecycle Issues

Problem: Transaction becomes inactive.

// Bad: Transaction finishes before async operation
async function badUpdate(id) {
  const db = await openDatabase();
  const tx = db.transaction(['tasks'], 'readwrite');
  const store = tx.objectStore('tasks');

  // This async operation happens AFTER transaction completes
  const newData = await fetch('/api/data').then((r) => r.json());

  // ERROR: Transaction is already finished
  store.put({ id, ...newData });
}

// Good: Fetch data before starting transaction
async function goodUpdate(id) {
  const newData = await fetch('/api/data').then((r) => r.json());

  const db = await openDatabase();
  const tx = db.transaction(['tasks'], 'readwrite');
  const store = tx.objectStore('tasks');

  // Transaction still active
  store.put({ id, ...newData });
}

3. Memory Leaks

// Bad: Keeping database reference
let globalDB;

function leakyOpen() {
  const request = indexedDB.open('MyDB', 1);
  request.onsuccess = () => {
    globalDB = request.result; // Prevents garbage collection
  };
}

// Good: Use connection pool or close when done
class DBConnectionPool {
  constructor() {
    this.connections = new WeakMap();
  }

  async getConnection(dbName) {
    // Get or create connection
  }

  closeAll() {
    // Close all connections
  }
}

Performance Monitoring

class IndexedDBPerformanceMonitor {
  constructor() {
    this.metrics = [];
  }

  async measureOperation(name, operation) {
    const start = performance.now();

    try {
      const result = await operation();
      const duration = performance.now() - start;

      this.metrics.push({
        name,
        duration,
        success: true,
        timestamp: Date.now(),
      });

      console.log(`${name}: ${duration.toFixed(2)}ms`);
      return result;
    } catch (error) {
      const duration = performance.now() - start;

      this.metrics.push({
        name,
        duration,
        success: false,
        error: error.message,
        timestamp: Date.now(),
      });

      throw error;
    }
  }

  getAverageTime(operationName) {
    const operations = this.metrics.filter((m) => m.name === operationName);
    if (operations.length === 0) return 0;

    const total = operations.reduce((sum, op) => sum + op.duration, 0);
    return total / operations.length;
  }

  getReport() {
    const operations = {};

    this.metrics.forEach((metric) => {
      if (!operations[metric.name]) {
        operations[metric.name] = {
          count: 0,
          totalTime: 0,
          successes: 0,
          failures: 0,
        };
      }

      const op = operations[metric.name];
      op.count++;
      op.totalTime += metric.duration;
      if (metric.success) op.successes++;
      else op.failures++;
    });

    return Object.entries(operations).map(([name, stats]) => ({
      operation: name,
      averageTime: (stats.totalTime / stats.count).toFixed(2),
      totalCalls: stats.count,
      successRate: ((stats.successes / stats.count) * 100).toFixed(2),
    }));
  }
}

// Usage
const monitor = new IndexedDBPerformanceMonitor();

await monitor.measureOperation('Add Task', () => addTask({ title: 'Test', priority: 'high' }));

await monitor.measureOperation('Get All Tasks', () => getAllTasks());

console.table(monitor.getReport());

Conclusion

IndexedDB is a powerful tool for building modern web applications that require substantial client-side data storage. While it has a steeper learning curve compared to localStorage, its capabilities far exceed simpler storage solutions.

Key Takeaways

  1. Use IndexedDB for large, structured data that needs to persist across sessions
  2. Implement proper error handling and fallbacks for better user experience
  3. Leverage indexes for fast queries on non-key properties
  4. Batch operations within transactions for optimal performance
  5. Always close database connections to prevent memory leaks
  6. Test thoroughly across different browsers and scenarios
  7. Consider security implications and encrypt sensitive data

When to Use IndexedDB

Good for:

  • Offline-first applications
  • PWAs requiring data synchronization
  • Storing large files or binary data
  • Complex querying requirements
  • Applications with thousands of records

Avoid for:

  • Simple key-value storage (use localStorage)
  • Temporary session data (use sessionStorage)
  • Server-side data storage
  • Cross-domain data sharing

Next Steps

IndexedDB empowers you to build sophisticated web applications that rival native apps in functionality and user experience. Master it, and you’ll unlock new possibilities for offline-capable, high-performance web applications.


Further Reading:

Tags: #IndexedDB #WebDevelopment #JavaScript #ClientSideStorage #PWA #OfflineFirst #WebAPI #BrowserDatabase #DataStorage