DEV Community

Cover image for Master IndexedDB: Advanced Techniques for Offline-First Web Applications
Aarav Joshi
Aarav Joshi

Posted on

1

Master IndexedDB: Advanced Techniques for Offline-First Web Applications

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

JavaScript IndexedDB is a powerful client-side storage solution built into modern browsers. I've spent years working with this technology and find it essential for creating robust web applications that maintain functionality even when users go offline.

IndexedDB offers a structured, transactional database system that can handle significant amounts of data. Unlike simpler storage options like localStorage, it provides advanced querying capabilities and can store virtually any type of JavaScript objects.

Understanding Transaction Management

Working effectively with IndexedDB requires mastering transactions. Every operation must occur within a transaction, which provides isolation and data integrity.

I always create explicit transaction scopes with the appropriate mode. For reading data, the 'readonly' mode is sufficient and allows for concurrent operations. When writing data, 'readwrite' mode is necessary.

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

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

    transaction.oncomplete = () => console.log('Transaction completed');
    transaction.onerror = () => reject(transaction.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

A common mistake I made early on was not handling transaction completion and errors properly. This can lead to database locks, especially when multiple transactions attempt to modify the same data.

Implementing Schema Versioning

As applications evolve, database schemas need to change. IndexedDB handles this through versioning and the 'onupgradeneeded' event.

I've learned to implement proper migration paths between versions to prevent data loss during upgrades.

const dbVersion = 2;
const request = indexedDB.open('MyDatabase', dbVersion);

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

  if (oldVersion < 1) {
    // First version - create initial stores
    const userStore = db.createObjectStore('users', { keyPath: 'id' });
    userStore.createIndex('email', 'email', { unique: true });
  }

  if (oldVersion < 2) {
    // Upgrade to version 2
    const userStore = request.transaction.objectStore('users');
    userStore.createIndex('name', 'name', { unique: false });

    // Create a new store
    const settingsStore = db.createObjectStore('settings', { keyPath: 'id' });
  }
};
Enter fullscreen mode Exit fullscreen mode

When adding new object stores or indices, I carefully consider backward compatibility. Users might not update immediately, so the application should handle multiple schema versions gracefully.

Optimizing Indices

Indices significantly improve query performance but come with storage and update costs. I'm strategic about which fields to index.

For frequently queried fields, creating indices is essential. However, over-indexing can slow down write operations and increase database size.

// Creating a simple index
objectStore.createIndex('createdAt', 'createdAt', { unique: false });

// Creating a compound index for more complex queries
objectStore.createIndex('userRegion', ['userId', 'region'], { unique: false });
Enter fullscreen mode Exit fullscreen mode

I've found compound indices particularly useful for filtering data based on multiple criteria. They allow for efficient range queries when data needs to be selected based on several properties.

Performing Bulk Operations

When dealing with large datasets, processing records one by one can be inefficient. Cursors provide a way to iterate through records without loading everything into memory at once.

function deleteOldRecords(storeName, cutoffDate) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(storeName, 'readwrite');
    const store = transaction.objectStore(storeName);
    const index = store.index('createdAt');
    const range = IDBKeyRange.upperBound(cutoffDate);

    let deleteCount = 0;

    index.openCursor(range).onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        cursor.delete();
        deleteCount++;
        cursor.continue();
      }
    };

    transaction.oncomplete = () => resolve(deleteCount);
    transaction.onerror = () => reject(transaction.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

For inserting multiple records, I batch the operations within a single transaction to improve performance:

function bulkInsert(storeName, records) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(storeName, 'readwrite');
    const store = transaction.objectStore(storeName);

    records.forEach(record => {
      store.add(record);
    });

    transaction.oncomplete = () => resolve();
    transaction.onerror = () => reject(transaction.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Creating Promise Wrappers

The IndexedDB API relies heavily on event handlers, which can lead to callback nesting and code that's difficult to maintain. I've found that creating Promise-based wrappers makes the code much cleaner.

class IndexedDBWrapper {
  constructor(dbName, version) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }

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

      request.onupgradeneeded = (event) => {
        this.db = event.target.result;
        this.upgrade(this.db, event.oldVersion);
      };

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

      request.onerror = () => {
        reject(request.error);
      };
    });
  }

  upgrade(db, oldVersion) {
    // Override this method to handle version upgrades
  }

  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);
    });
  }

  // Additional methods for put, delete, etc.
}
Enter fullscreen mode Exit fullscreen mode

This approach allows for easy chaining of operations and integration with async/await syntax:

async function updateUserPreferences(userId, newPrefs) {
  try {
    const user = await db.get('users', userId);
    user.preferences = {...user.preferences, ...newPrefs};
    await db.put('users', user);
    return user;
  } catch (error) {
    console.error('Failed to update user preferences:', error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Working with Binary Storage

IndexedDB excels at storing binary data like images, audio files, or any Blob or ArrayBuffer objects. This capability makes it valuable for offline-first applications that need to cache resources.

async function saveImage(imageBlob, imageName) {
  const transaction = db.transaction(['images'], 'readwrite');
  const store = transaction.objectStore('images');

  return new Promise((resolve, reject) => {
    const request = store.put({
      id: imageName,
      data: imageBlob,
      timestamp: Date.now()
    });

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

async function loadImage(imageName) {
  const transaction = db.transaction(['images'], 'readonly');
  const store = transaction.objectStore('images');

  return new Promise((resolve, reject) => {
    const request = store.get(imageName);

    request.onsuccess = () => {
      if (request.result) {
        const imgBlob = request.result.data;
        const imgUrl = URL.createObjectURL(imgBlob);
        resolve(imgUrl);
      } else {
        resolve(null);
      }
    };

    request.onerror = () => reject(request.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

When storing binary data, I've found it important to monitor storage usage and implement cleanup strategies to prevent exceeding browser storage limits.

Query Optimization Techniques

For applications with large datasets, efficient querying becomes critical. IndexedDB provides key ranges and cursors for optimized data retrieval.

function findRecordsInDateRange(startDate, endDate) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['events'], 'readonly');
    const store = transaction.objectStore('events');
    const index = store.index('date');
    const range = IDBKeyRange.bound(startDate, endDate);

    const results = [];

    index.openCursor(range).onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        results.push(cursor.value);
        cursor.continue();
      }
    };

    transaction.oncomplete = () => resolve(results);
    transaction.onerror = () => reject(transaction.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

For large result sets, implementing pagination is essential for performance:

function paginateRecords(storeName, pageSize, pageNumber) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([storeName], 'readonly');
    const store = transaction.objectStore(storeName);

    let advanceCount = pageSize * (pageNumber - 1);
    let results = [];
    let count = 0;

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

      if (!cursor) return;

      if (advanceCount > 0) {
        advanceCount--;
        cursor.advance(1);
        return;
      }

      if (count < pageSize) {
        results.push(cursor.value);
        count++;
        cursor.continue();
      }
    };

    transaction.oncomplete = () => resolve(results);
    transaction.onerror = () => reject(transaction.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Handling Database Errors and Recovery

In my experience, error handling is crucial when working with IndexedDB. Browser storage can fail for various reasons, from quota exceeded to unexpected browser behavior.

function safeDBOperation(operation) {
  return new Promise((resolve, reject) => {
    try {
      operation().then(resolve).catch(error => {
        console.error('IndexedDB operation failed:', error);

        if (error.name === 'QuotaExceededError') {
          // Handle storage limit reached
          cleanupOldData().then(() => {
            // Retry the operation
            return operation();
          }).then(resolve).catch(reject);
        } else {
          reject(error);
        }
      });
    } catch (error) {
      console.error('Unexpected IndexedDB error:', error);
      reject(error);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

I also implement recovery mechanisms for situations where the database might be corrupted:

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

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

      // Backup existing data if possible
      try {
        backupExistingData(db, event.oldVersion);
      } catch (error) {
        console.warn('Could not backup existing data:', error);
      }

      // Recreate schema
      setupSchema(db);
    };

    request.onsuccess = () => {
      const db = request.result;

      // Validate critical stores exist
      const storeNames = Array.from(db.objectStoreNames);
      const requiredStores = ['users', 'settings', 'data'];

      const missingStores = requiredStores.filter(
        store => !storeNames.includes(store)
      );

      if (missingStores.length) {
        console.warn('Database missing required stores:', missingStores);
        db.close();

        // Force schema recreation
        const deleteRequest = indexedDB.deleteDatabase(dbName);
        deleteRequest.onsuccess = () => {
          const reopenRequest = indexedDB.open(dbName, dbVersion);
          // Set up handlers again...
        };
      } else {
        resolve(db);
      }
    };

    request.onerror = () => reject(request.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Synchronization Strategies

For offline-first applications, synchronizing IndexedDB with server data is a common requirement. I've implemented various synchronization patterns:

async function syncWithServer() {
  // 1. Get records that need to be synced
  const recordsToSync = await getUnsynced();

  // 2. Get server changes since last sync
  const lastSyncTimestamp = await getLastSyncTimestamp();
  const serverChanges = await fetchServerChanges(lastSyncTimestamp);

  // 3. Apply server changes to local database
  await applyServerChanges(serverChanges);

  // 4. Send local changes to server
  const syncResults = await sendChangesToServer(recordsToSync);

  // 5. Update sync status for successful records
  await markAsSynced(syncResults.successful);

  // 6. Update last sync timestamp
  await updateSyncTimestamp();

  return {
    syncedToServer: syncResults.successful.length,
    syncedFromServer: serverChanges.length,
    failed: syncResults.failed.length
  };
}

async function applyServerChanges(changes) {
  const transaction = db.transaction(['data'], 'readwrite');
  const store = transaction.objectStore('data');

  return new Promise((resolve, reject) => {
    changes.forEach(change => {
      if (change.deleted) {
        store.delete(change.id);
      } else {
        store.put(change);
      }
    });

    transaction.oncomplete = () => resolve();
    transaction.onerror = () => reject(transaction.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

For conflict resolution, I implement strategies based on timestamps, version vectors, or domain-specific rules:

function resolveConflict(localRecord, serverRecord) {
  // Simple timestamp-based resolution
  if (localRecord.updatedAt > serverRecord.updatedAt) {
    return localRecord;
  } else if (serverRecord.updatedAt > localRecord.updatedAt) {
    return serverRecord;
  }

  // If timestamps match, we need more sophisticated resolution
  return mergeRecords(localRecord, serverRecord);
}

function mergeRecords(local, server) {
  // Field-by-field merge based on business rules
  return {
    ...server,
    // Keep local values for certain fields if they're "better"
    notes: local.notes || server.notes,
    // Merge arrays
    tags: [...new Set([...local.tags, ...server.tags])],
    // Custom merge logic for complex fields
    preferences: mergePreferences(local.preferences, server.preferences)
  };
}
Enter fullscreen mode Exit fullscreen mode

Performance Monitoring

To ensure IndexedDB operations don't affect application responsiveness, I implement performance monitoring:

class PerformanceTracker {
  constructor() {
    this.operations = {};
  }

  startOperation(name) {
    if (!this.operations[name]) {
      this.operations[name] = {
        count: 0,
        totalTime: 0,
        maxTime: 0
      };
    }

    return {
      name,
      startTime: performance.now()
    };
  }

  endOperation(operation) {
    const endTime = performance.now();
    const duration = endTime - operation.startTime;

    const stats = this.operations[operation.name];
    stats.count++;
    stats.totalTime += duration;
    stats.maxTime = Math.max(stats.maxTime, duration);

    if (duration > 100) {
      console.warn(`Slow IndexedDB operation: ${operation.name} took ${duration.toFixed(2)}ms`);
    }

    return duration;
  }

  getStats() {
    const result = {};

    for (const [name, stats] of Object.entries(this.operations)) {
      result[name] = {
        ...stats,
        avgTime: stats.count > 0 ? stats.totalTime / stats.count : 0
      };
    }

    return result;
  }
}

const perfTracker = new PerformanceTracker();

async function trackedDBOperation(name, operation) {
  const tracker = perfTracker.startOperation(name);
  try {
    const result = await operation();
    return result;
  } finally {
    perfTracker.endOperation(tracker);
  }
}
Enter fullscreen mode Exit fullscreen mode

Working with IndexedDB requires careful attention to detail, but the effort pays off in creating robust web applications. The techniques I've shared come from years of practical experience building systems that handle varying connectivity conditions.

By mastering these approaches, you can create applications that provide seamless experiences regardless of network availability. The ability to store and process data locally transforms web applications from network-dependent interfaces to powerful standalone tools.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay