Concepts to Know

IndexedDB Guide

Complete guide to using IndexedDB for powerful client-side storage

IndexedDB is your browser's built-in mini database — and it's awesome for storing large amounts of structured data right in the user's device. Whether you're building offline-first apps or need a smart way to cache data, IndexedDB's got your back.

Let's break it down into digestible, friendly bits

Why Use IndexedDB?

Here's what makes IndexedDB a standout tool:

1. Structured, Object-Based Storage

Think of it like a spreadsheet or a mini database:

  • Data is stored as JavaScript objects (not just strings like in localStorage)
  • You use keys to access or index that data
  • Supports complex data structures and relationships

2. Asynchronous by Design

  • All operations (like put, get, or delete) run asynchronously, meaning they don't block your UI
  • Perfect for maintaining a smooth, snappy user experience

3. Stores a LOT of Data

  • Need more space than cookies or localStorage? IndexedDB lets you store hundreds of MBs — or even more — depending on the browser

4. Cross-Tab Sharing (No Same-Origin Policy Limits)

  • IndexedDB is origin-specific but can be accessed across tabs/windows of the same site. Great for syncing data!

5. Transactional Integrity

  • Operations run inside transactions, which means if one step fails, the whole thing rolls back. This keeps your data consistent and safe

6. Powerful Indexing

  • You can create custom indexes on your object properties to query large datasets quickly — like searching for a user by email

7. Works Offline Like a Champ

  • Combine IndexedDB with service workers or PWA techniques and boom — your app can work without the internet

8. Private by Design

  • Data is locked to your app's origin (protocol + domain + port), so other sites can't touch it

9. Fully Supported

  • Works smoothly in all major browsers: Chrome, Firefox, Edge, Safari, and more

Getting Started with IndexedDB

Basic Setup

// Open or create a database
const request = indexedDB.open('MyAppDB', 1);

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

request.onsuccess = (event) => {
  const db = event.target.result;
  console.log('Database opened successfully');
};

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  
  // Create object store (like a table)
  if (!db.objectStoreNames.contains('users')) {
    const userStore = db.createObjectStore('users', { keyPath: 'id' });
    
    // Create indexes for faster queries
    userStore.createIndex('email', 'email', { unique: true });
    userStore.createIndex('name', 'name', { unique: false });
  }
};

Wrapper Class for Easier Usage

class IndexedDBManager {
  constructor(dbName, version) {
    this.dbName = dbName;
    this.version = version;
    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;
        
        // Create stores
        if (!db.objectStoreNames.contains('users')) {
          const userStore = db.createObjectStore('users', { keyPath: 'id' });
          userStore.createIndex('email', 'email', { unique: true });
        }
        
        if (!db.objectStoreNames.contains('posts')) {
          const postStore = db.createObjectStore('posts', { keyPath: 'id' });
          postStore.createIndex('userId', 'userId', { unique: false });
          postStore.createIndex('createdAt', 'createdAt', { unique: false });
        }
      };
    });
  }

  async add(storeName, data) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.add(data);

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

  async get(storeName, key) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], 'readonly');
      const store = transaction.objectStore(storeName);
      const request = store.get(key);

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

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

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

  async update(storeName, data) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.put(data);

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

  async delete(storeName, key) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.delete(key);

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

  async queryByIndex(storeName, indexName, value) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], 'readonly');
      const store = transaction.objectStore(storeName);
      const index = store.index(indexName);
      const request = index.getAll(value);

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

Real-World Examples

User Management System

// Initialize the database
const dbManager = new IndexedDBManager('UserApp', 1);

// User operations
class UserService {
  async addUser(user) {
    const userData = {
      id: Date.now().toString(),
      ...user,
      createdAt: new Date().toISOString()
    };
    
    await dbManager.add('users', userData);
    return userData;
  }

  async getUserById(id) {
    return await dbManager.get('users', id);
  }

  async getUserByEmail(email) {
    const users = await dbManager.queryByIndex('users', 'email', email);
    return users[0] || null;
  }

  async updateUser(user) {
    return await dbManager.update('users', user);
  }

  async deleteUser(id) {
    return await dbManager.delete('users', id);
  }

  async getAllUsers() {
    return await dbManager.getAll('users');
  }
}

// Usage example
const userService = new UserService();

// Add a user
const newUser = await userService.addUser({
  name: 'John Doe',
  email: 'john@example.com',
  age: 30
});

// Find user by email
const user = await userService.getUserByEmail('john@example.com');

Blog Post System with Relationships

class PostService {
  async addPost(post) {
    const postData = {
      id: Date.now().toString(),
      ...post,
      createdAt: new Date().toISOString()
    };
    
    await dbManager.add('posts', postData);
    return postData;
  }

  async getPostsByUser(userId) {
    return await dbManager.queryByIndex('posts', 'userId', userId);
  }

  async getRecentPosts(limit = 10) {
    const allPosts = await dbManager.getAll('posts');
    return allPosts
      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
      .slice(0, limit);
  }
}

// Usage
const postService = new PostService();

// Add a post
const post = await postService.addPost({
  title: 'My First Post',
  content: 'This is the content...',
  userId: newUser.id,
  tags: ['javascript', 'indexeddb']
});

// Get user's posts
const userPosts = await postService.getPostsByUser(newUser.id);

Advanced Patterns

Caching API Responses

class APICache {
  constructor() {
    this.dbManager = new IndexedDBManager('APICache', 1);
  }

  async cacheResponse(url, data, ttl = 3600000) { // 1 hour default
    const cacheEntry = {
      url,
      data,
      timestamp: Date.now(),
      ttl
    };
    
    await this.dbManager.update('apiCache', cacheEntry);
  }

  async getCachedResponse(url) {
    const entry = await this.dbManager.get('apiCache', url);
    
    if (!entry) return null;
    
    // Check if cache is still valid
    if (Date.now() - entry.timestamp > entry.ttl) {
      await this.dbManager.delete('apiCache', url);
      return null;
    }
    
    return entry.data;
  }

  async fetchWithCache(url, options = {}) {
    // Try cache first
    const cached = await this.getCachedResponse(url);
    if (cached) {
      return cached;
    }

    // Fetch from network
    const response = await fetch(url, options);
    const data = await response.json();
    
    // Cache the response
    await this.cacheResponse(url, data, options.ttl);
    
    return data;
  }
}

Offline-First Data Sync

class OfflineSync {
  constructor() {
    this.dbManager = new IndexedDBManager('OfflineApp', 1);
    this.syncQueue = [];
  }

  async addToSyncQueue(action) {
    const syncItem = {
      id: Date.now().toString(),
      action,
      timestamp: Date.now(),
      status: 'pending'
    };
    
    await this.dbManager.add('syncQueue', syncItem);
  }

  async syncWhenOnline() {
    if (!navigator.onLine) return;
    
    const pendingItems = await this.dbManager.queryByIndex('syncQueue', 'status', 'pending');
    
    for (const item of pendingItems) {
      try {
        // Attempt to sync with server
        await this.performSync(item.action);
        
        // Mark as completed
        item.status = 'completed';
        await this.dbManager.update('syncQueue', item);
      } catch (error) {
        console.error('Sync failed:', error);
        item.status = 'failed';
        await this.dbManager.update('syncQueue', item);
      }
    }
  }

  async performSync(action) {
    // Implement your sync logic here
    const response = await fetch('/api/sync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(action)
    });
    
    if (!response.ok) {
      throw new Error('Sync failed');
    }
    
    return response.json();
  }
}

Common Use Cases

Here's where IndexedDB really shines:

  • Caching API responses for offline or low-connectivity experiences
  • Saving user-generated content like notes, drafts, or form data
  • Storing large JSON datasets or file blobs
  • Syncing local app state across tabs
  • PWA offline functionality for mobile apps
  • Game state persistence for browser games
  • Analytics data buffering before sending to server

Important Considerations

Browser Support and Limitations

// Check for IndexedDB support
if (!window.indexedDB) {
  console.warn('IndexedDB not supported, falling back to localStorage');
  // Implement fallback
}

// Handle storage quota
async function checkStorageQuota() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    const usagePercent = (estimate.usage / estimate.quota) * 100;
    
    if (usagePercent > 80) {
      console.warn('Storage usage is high:', usagePercent.toFixed(1) + '%');
    }
  }
}

Error Handling

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

// Enhanced error handling
async function safeIndexedDBOperation(operation) {
  try {
    return await operation();
  } catch (error) {
    if (error.name === 'QuotaExceededError') {
      throw new IndexedDBError('Storage quota exceeded', error);
    } else if (error.name === 'VersionError') {
      throw new IndexedDBError('Database version mismatch', error);
    } else {
      throw new IndexedDBError('IndexedDB operation failed', error);
    }
  }
}

Performance Tips

  • Use transactions efficiently - group related operations
  • Create indexes wisely - only for frequently queried fields
  • Clean up old data - implement TTL (Time To Live) for cached data
  • Monitor storage usage - prevent quota exceeded errors
  • Use cursors for large datasets - avoid loading everything into memory

Final Thoughts

If your app needs robust local storage and you're ready to go beyond key-value strings, IndexedDB is a fantastic choice. It might feel complex at first, but once you get the hang of it, it unlocks a whole new level of browser capabilities

Want to try it out? Tools like idb (a small wrapper library) can make working with IndexedDB even easier.

Happy caching!


Next: Broadcast Channel API - Learn how to communicate between browser tabs and windows.