HOWTOIntermediate

Building PWAs with IndexedDB for Offline Data

Implement offline data persistence in Progressive Web Apps using IndexedDB. Covers database abstraction, CRUD operations, migration strategies, and sync...

Dylan H.

Software Engineering

February 2, 2026
6 min read
Building PWAs with IndexedDB for Offline Data

Prerequisites

  • JavaScript/TypeScript
  • React basics
  • Understanding of async/await

Overview

IndexedDB is the browser's built-in NoSQL database for structured offline data storage. Unlike localStorage (5MB limit, synchronous, strings only), IndexedDB handles megabytes of structured data with indexes and transactions. This guide builds a clean abstraction layer for using IndexedDB in PWAs.

Why IndexedDB?

FeaturelocalStorageIndexedDB
Storage Limit~5MB~50MB+ (varies by browser)
Data TypesStrings onlyObjects, arrays, blobs, files
AsyncNo (blocks UI)Yes (non-blocking)
IndexesNoYes (queryable)
TransactionsNoYes (ACID)

Process

Step 1: Database Abstraction

Wrap IndexedDB's callback-based API in a Promise-based class:

// lib/indexed-db.ts
 
interface DBConfig {
  name: string;
  version: number;
  stores: StoreConfig[];
}
 
interface StoreConfig {
  name: string;
  keyPath: string;
  indexes?: { name: string; keyPath: string; unique?: boolean }[];
}
 
export class LocalDB {
  private db: IDBDatabase | null = null;
  private config: DBConfig;
 
  constructor(config: DBConfig) {
    this.config = config;
  }
 
  async open(): Promise<IDBDatabase> {
    if (this.db) return this.db;
 
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.config.name, this.config.version);
 
      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        for (const store of this.config.stores) {
          if (!db.objectStoreNames.contains(store.name)) {
            const objectStore = db.createObjectStore(store.name, {
              keyPath: store.keyPath,
            });
            for (const index of store.indexes || []) {
              objectStore.createIndex(index.name, index.keyPath, {
                unique: index.unique || false,
              });
            }
          }
        }
      };
 
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this.db);
      };
 
      request.onerror = () => reject(request.error);
    });
  }
 
  async add<T>(storeName: string, item: T): Promise<IDBValidKey> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(storeName, "readwrite");
      const store = tx.objectStore(storeName);
      const request = store.add(item);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
 
  async put<T>(storeName: string, item: T): Promise<IDBValidKey> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(storeName, "readwrite");
      const store = tx.objectStore(storeName);
      const request = store.put(item);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
 
  async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(storeName, "readonly");
      const store = tx.objectStore(storeName);
      const request = store.get(key);
      request.onsuccess = () => resolve(request.result as T);
      request.onerror = () => reject(request.error);
    });
  }
 
  async getAll<T>(storeName: string): Promise<T[]> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(storeName, "readonly");
      const store = tx.objectStore(storeName);
      const request = store.getAll();
      request.onsuccess = () => resolve(request.result as T[]);
      request.onerror = () => reject(request.error);
    });
  }
 
  async delete(storeName: string, key: IDBValidKey): Promise<void> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(storeName, "readwrite");
      const store = tx.objectStore(storeName);
      const request = store.delete(key);
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }
 
  async query<T>(
    storeName: string,
    indexName: string,
    value: IDBValidKey
  ): Promise<T[]> {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(storeName, "readonly");
      const store = tx.objectStore(storeName);
      const index = store.index(indexName);
      const request = index.getAll(value);
      request.onsuccess = () => resolve(request.result as T[]);
      request.onerror = () => reject(request.error);
    });
  }
}

Step 2: Application-Specific Database

// lib/app-db.ts
import { LocalDB } from "./indexed-db";
 
export interface Transaction {
  id: string;
  amount: number;
  category: string;
  description: string;
  date: string;
  type: "income" | "expense";
  synced: boolean;
  updatedAt: string;
}
 
const db = new LocalDB({
  name: "budget-tracker",
  version: 2,
  stores: [
    {
      name: "transactions",
      keyPath: "id",
      indexes: [
        { name: "date", keyPath: "date" },
        { name: "category", keyPath: "category" },
        { name: "synced", keyPath: "synced" },
        { name: "type", keyPath: "type" },
      ],
    },
    {
      name: "categories",
      keyPath: "id",
      indexes: [{ name: "name", keyPath: "name", unique: true }],
    },
  ],
});
 
export const transactionStore = {
  async add(tx: Omit<Transaction, "id" | "synced" | "updatedAt">) {
    const record: Transaction = {
      ...tx,
      id: crypto.randomUUID(),
      synced: false,
      updatedAt: new Date().toISOString(),
    };
    await db.put("transactions", record);
    return record;
  },
 
  async getAll() {
    return db.getAll<Transaction>("transactions");
  },
 
  async getByCategory(category: string) {
    return db.query<Transaction>("transactions", "category", category);
  },
 
  async getUnsynced() {
    return db.query<Transaction>("transactions", "synced", false as any);
  },
 
  async markSynced(ids: string[]) {
    for (const id of ids) {
      const tx = await db.get<Transaction>("transactions", id);
      if (tx) {
        await db.put("transactions", { ...tx, synced: true });
      }
    }
  },
 
  async delete(id: string) {
    await db.delete("transactions", id);
  },
};

Step 3: React Hook for Database Access

// hooks/useTransactions.ts
"use client";
 
import { useState, useEffect, useCallback } from "react";
import { transactionStore, Transaction } from "@/lib/app-db";
 
export function useTransactions() {
  const [transactions, setTransactions] = useState<Transaction[]>([]);
  const [loading, setLoading] = useState(true);
 
  const refresh = useCallback(async () => {
    const data = await transactionStore.getAll();
    // Sort by date descending
    data.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
    setTransactions(data);
    setLoading(false);
  }, []);
 
  useEffect(() => {
    refresh();
  }, [refresh]);
 
  const add = async (tx: Omit<Transaction, "id" | "synced" | "updatedAt">) => {
    const record = await transactionStore.add(tx);
    await refresh();
    return record;
  };
 
  const remove = async (id: string) => {
    await transactionStore.delete(id);
    await refresh();
  };
 
  const totals = {
    income: transactions
      .filter((t) => t.type === "income")
      .reduce((sum, t) => sum + t.amount, 0),
    expenses: transactions
      .filter((t) => t.type === "expense")
      .reduce((sum, t) => sum + t.amount, 0),
    get balance() {
      return this.income - this.expenses;
    },
  };
 
  return { transactions, loading, add, remove, refresh, totals };
}

Step 4: Migration Strategy

Handle schema changes when upgrading the database version:

// lib/migrations.ts
 
export function handleUpgrade(db: IDBDatabase, oldVersion: number) {
  // Version 1 → 2: Add 'type' index to transactions
  if (oldVersion < 2) {
    if (db.objectStoreNames.contains("transactions")) {
      const tx = db.transaction("transactions", "readwrite");
      const store = tx.objectStore("transactions");
      if (!store.indexNames.contains("type")) {
        store.createIndex("type", "type");
      }
    }
  }
 
  // Version 2 → 3: Add 'budgets' store
  if (oldVersion < 3) {
    db.createObjectStore("budgets", { keyPath: "id" });
  }
}

Storage Limits by Browser

BrowserDefault LimitEviction Policy
Chrome60% of disk spaceLRU when quota exceeded
Firefox50% of disk spaceLRU per origin
Safari1GB (asks for more)7-day eviction without interaction
EdgeSame as ChromeSame as Chrome

Key Takeaways

  • Wrap IndexedDB's callback API in Promises for clean async/await usage
  • Use indexes for any field you'll query by — they make lookups O(1) instead of O(n)
  • Track synced status on each record for reliable cloud sync
  • Use crypto.randomUUID() for IDs — works offline without server coordination
  • Version your schema with onupgradeneeded for safe migrations
  • Safari evicts data after 7 days without user interaction — persist important data to cloud