Skip to main content
COSMICBYTEZLABS
NewsSecurityHOWTOsToolsStudyTraining
ProjectsChecklistsAI RankingsNewsletterStatusTagsAbout
Subscribe

Press Enter to search or Esc to close

News
Security
HOWTOs
Tools
Study
Training
Projects
Checklists
AI Rankings
Newsletter
Status
Tags
About
RSS Feed
Reading List
Subscribe

Stay in the Loop

Get the latest security alerts, tutorials, and tech insights delivered to your inbox.

Subscribe NowFree forever. No spam.
COSMICBYTEZLABS

Your trusted source for IT intelligence, cybersecurity insights, and hands-on technical guides.

429+ Articles
114+ Guides

CONTENT

  • Latest News
  • Security Alerts
  • HOWTOs
  • Projects
  • Exam Prep

RESOURCES

  • Search
  • Browse Tags
  • Newsletter Archive
  • Reading List
  • RSS Feed

COMPANY

  • About Us
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CosmicBytez Labs. All rights reserved.

System Status: Operational
  1. Home
  2. HOWTOs
  3. Building PWAs with IndexedDB for Offline Data
Building PWAs with IndexedDB for Offline Data
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

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
#PWA#IndexedDB#Offline#JavaScript#Storage

Related Articles

Building Offline-First PWAs with Next.js and SQLite

Learn how to build a Progressive Web App with offline-first architecture using Next.js, SQLite for local storage, and Supabase for cloud sync. Includes...

5 min read

Container Security Scanning with Trivy: Images, IaC, and CI/CD

Learn how to use Trivy to scan container images, Dockerfiles, Kubernetes manifests, and Terraform for vulnerabilities and misconfigurations — then integrate it into your GitHub Actions pipeline.

7 min read

HashiCorp Vault: Centralized Secrets Management for Modern Infrastructure

Deploy and configure HashiCorp Vault to securely store, rotate, and audit secrets across your infrastructure — covering installation, auth methods,...

8 min read
Back to all HOWTOs