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?
| Feature | localStorage | IndexedDB |
|---|---|---|
| Storage Limit | ~5MB | ~50MB+ (varies by browser) |
| Data Types | Strings only | Objects, arrays, blobs, files |
| Async | No (blocks UI) | Yes (non-blocking) |
| Indexes | No | Yes (queryable) |
| Transactions | No | Yes (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
| Browser | Default Limit | Eviction Policy |
|---|---|---|
| Chrome | 60% of disk space | LRU when quota exceeded |
| Firefox | 50% of disk space | LRU per origin |
| Safari | 1GB (asks for more) | 7-day eviction without interaction |
| Edge | Same as Chrome | Same 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
syncedstatus on each record for reliable cloud sync - Use
crypto.randomUUID()for IDs — works offline without server coordination - Version your schema with
onupgradeneededfor safe migrations - Safari evicts data after 7 days without user interaction — persist important data to cloud