← All writing

Wonders of IndexedDB and Offline-Mode

Illustration accompanying Wonders of IndexedDB and Offline-Mode

There's something magical about building a web application that works perfectly even when the internet connection drops. I discovered this magic while building a retail audit system that needed to function in remote stores with unreliable connectivity. The challenge was clear: field auditors needed to collect product information, pricing data, and availability details, but many locations had poor or no internet connection. The solution? IndexedDB and a well-architected offline-first approach.

IndexedDB is often misunderstood. Developers see it as a complex, low-level API and reach for wrapper libraries instead of understanding what it actually offers. But once you grasp its concepts, IndexedDB becomes an incredibly powerful tool for building applications that feel native, responsive, and reliable regardless of network conditions.

The fundamental concept behind IndexedDB is that it's a transactional, object-oriented database that lives in the browser. Unlike localStorage which stores simple key-value pairs, IndexedDB can store complex objects, handle large amounts of data, and perform efficient queries. It's asynchronous by design, which means your UI stays responsive even when dealing with thousands of records.

Opening an IndexedDB Database

const openDatabase = () => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("RetailAuditDB", 1);

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

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

      // Create object stores (think of them as tables)
      if (!db.objectStoreNames.contains("products")) {
        const productStore = db.createObjectStore("products", {
          keyPath: "id",
          autoIncrement: true,
        });

        // Create indexes for efficient queries
        productStore.createIndex("barcode", "barcode", { unique: false });
        productStore.createIndex("storeId", "storeId", { unique: false });
        productStore.createIndex("category", "category", { unique: false });
      }

      if (!db.objectStoreNames.contains("audits")) {
        const auditStore = db.createObjectStore("audits", {
          keyPath: "id",
          autoIncrement: true,
        });

        auditStore.createIndex("storeId", "storeId", { unique: false });
        auditStore.createIndex("date", "date", { unique: false });
        auditStore.createIndex("status", "status", { unique: false });
      }
    };
  });
};

The upgrade handler is where you define your database schema. This runs when you first create the database or when you increment the version number. It's the perfect place to create object stores and indexes. Indexes are crucial for performance - they allow you to query data efficiently without scanning every record.

Once your database is open, storing data becomes straightforward. You create a transaction, get a reference to the object store, and perform your operations. The transaction model ensures data consistency - either all operations in a transaction succeed, or none of them do.

Storing Data in IndexedDB

const saveProduct = async (product) => {
  const db = await openDatabase();
  const transaction = db.transaction(["products"], "readwrite");
  const store = transaction.objectStore("products");

  return new Promise((resolve, reject) => {
    const request = store.add({
      barcode: product.barcode,
      name: product.name,
      price: product.price,
      category: product.category,
      storeId: product.storeId,
      lastUpdated: new Date().toISOString(),
      synced: false,
    });

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

The real power emerges when you start querying. IndexedDB supports range queries, which means you can retrieve all products in a specific price range, or all audits from a date range. You can also use cursors to iterate through large datasets efficiently.

Querying with Indexes

const getProductsByStore = async (storeId) => {
  const db = await openDatabase();
  const transaction = db.transaction(["products"], "readonly");
  const store = transaction.objectStore("products");
  const index = store.index("storeId");

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

const getUnsyncedAudits = async () => {
  const db = await openDatabase();
  const transaction = db.transaction(["audits"], "readonly");
  const store = transaction.objectStore("audits");
  const index = store.index("status");

  return new Promise((resolve, reject) => {
    const results = [];
    const request = index.openCursor(IDBKeyRange.only("pending"));

    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results);
      }
    };

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

The offline-first pattern I implemented works like this: every user action writes to IndexedDB first, then attempts to sync with the server. If the sync succeeds, we mark the record as synced. If it fails, we keep it marked as unsynced and retry when connectivity is restored.

Offline-First Data Flow

const saveAuditOfflineFirst = async (auditData) => {
  // Always save locally first
  const localId = await saveAuditToIndexedDB(auditData);

  // Try to sync immediately
  try {
    const response = await fetch("/api/audits", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(auditData),
    });

    if (response.ok) {
      const serverData = await response.json();
      // Update local record with server ID and mark as synced
      await updateAuditInIndexedDB(localId, {
        serverId: serverData.id,
        synced: true,
        syncedAt: new Date().toISOString(),
      });
    }
  } catch (error) {
    // Network error - data is saved locally, will sync later
    console.log("Offline: Data saved locally, will sync when online");
  }

  return localId;
};

The sync mechanism runs in the background, checking for connectivity and attempting to sync unsynced records. I use the Network Information API and online/offline events to trigger sync attempts, but also run periodic syncs to catch any missed records.

Background Sync Service

class SyncService {
  constructor() {
    this.syncing = false;
    this.setupEventListeners();
    this.startPeriodicSync();
  }

  setupEventListeners() {
    window.addEventListener("online", () => {
      console.log("Connection restored, starting sync...");
      this.syncPendingRecords();
    });

    window.addEventListener("offline", () => {
      console.log("Connection lost, operating in offline mode");
    });
  }

  startPeriodicSync() {
    // Sync every 30 seconds when online
    setInterval(async () => {
      if (navigator.onLine && !this.syncing) {
        await this.syncPendingRecords();
      }
    }, 30000);
  }

  async syncPendingRecords() {
    if (this.syncing) return;
    this.syncing = true;

    try {
      const unsyncedAudits = await getUnsyncedAudits();

      for (const audit of unsyncedAudits) {
        try {
          const response = await fetch("/api/audits", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(audit),
          });

          if (response.ok) {
            const serverData = await response.json();
            await updateAuditInIndexedDB(audit.id, {
              serverId: serverData.id,
              synced: true,
              syncedAt: new Date().toISOString(),
            });
          }
        } catch (error) {
          console.error(`Failed to sync audit ${audit.id}:`, error);
        }
      }
    } finally {
      this.syncing = false;
    }
  }
}

One of the most powerful features I discovered is using IndexedDB with Service Workers for true offline functionality. When you combine IndexedDB with a Service Worker that caches your application shell and API responses, you get an application that works completely offline from the first load.

Service Worker with IndexedDB

// In your service worker
self.addEventListener("fetch", (event) => {
  if (event.request.url.includes("/api/")) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          // Cache successful responses
          const clone = response.clone();
          caches.open("api-cache").then((cache) => {
            cache.put(event.request, clone);
          });
          return response;
        })
        .catch(() => {
          // Offline: try to get from IndexedDB via postMessage
          return new Response(
            JSON.stringify({ error: "Offline, data from local storage" }),
            { headers: { "Content-Type": "application/json" } }
          );
        })
    );
  }
});

Handling conflicts is crucial in offline-first applications. When a record is modified both locally and on the server, you need a conflict resolution strategy. I typically use a "last write wins" approach for simple cases, but for critical data, I implement more sophisticated merge strategies.

Conflict Resolution

const handleSyncConflict = async (localRecord, serverRecord) => {
  const localTime = new Date(localRecord.lastModified);
  const serverTime = new Date(serverRecord.lastModified);

  if (serverTime > localTime) {
    // Server version is newer, update local
    await updateAuditInIndexedDB(localRecord.id, {
      ...serverRecord,
      synced: true,
    });
  } else {
    // Local version is newer, send to server
    const response = await fetch(`/api/audits/${serverRecord.id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(localRecord),
    });

    if (response.ok) {
      const updated = await response.json();
      await updateAuditInIndexedDB(localRecord.id, {
        ...updated,
        synced: true,
      });
    }
  }
};

The performance benefits of IndexedDB become apparent when dealing with large datasets. I've stored millions of product records in IndexedDB, and queries remain fast because of the indexed access patterns. The key is designing your indexes based on your query patterns.

Building offline-first applications with IndexedDB taught me that users don't care about your technical architecture - they care about whether the app works when they need it. An application that gracefully handles offline scenarios feels more reliable and professional than one that shows error messages when connectivity is poor.

The combination of IndexedDB for local storage, Service Workers for caching, and a well-designed sync mechanism creates web applications that rival native apps in terms of reliability and user experience. The technology has been around for years, but it's only recently that developers are fully embracing its potential to build truly resilient web applications.