Dexie.js vs localForage vs idb: IndexedDB in the Browser (2026)
TL;DR
Dexie.js is the most developer-friendly IndexedDB wrapper — table-based API, rich querying (range queries, indexes, compound queries), TypeScript-first, and it works seamlessly with React via useLiveQuery. localForage wraps IndexedDB (with localStorage/WebSQL fallbacks) with a simple setItem/getItem API — minimal learning curve, great for key-value offline storage. idb (from Jake Archibald) is the thinnest possible IndexedDB wrapper — promisified raw IndexedDB API with no abstraction overhead. In 2026: Dexie.js for any app with real offline data needs, localForage for simple key-value caching, idb if you need full IndexedDB control with minimal overhead.
Key Takeaways
- dexie: ~3M weekly downloads — full ORM-like API for IndexedDB, live queries, sync with cloud
- localforage: ~5M weekly downloads — simple key-value, localStorage-compatible API, multiple backends
- idb: ~3M weekly downloads — thin promisified wrapper, full IndexedDB access, no abstractions
- IndexedDB stores gigabytes; localStorage is limited to ~5MB (and is synchronous/blocking)
- Dexie's
useLiveQuerymakes React components automatically re-render when data changes localForagefalls back to WebSQL or localStorage when IndexedDB isn't available (rare in 2026)
Why Not localStorage?
// localStorage — simple but limited:
localStorage.setItem("user", JSON.stringify({ id: 1, name: "Royce" }))
const user = JSON.parse(localStorage.getItem("user") ?? "{}")
// Problems:
// 1. Synchronous — BLOCKS THE MAIN THREAD on every read/write
// 2. ~5MB limit — can't store large datasets
// 3. Strings only — manual JSON serialization
// 4. No transactions — partial writes can corrupt data
// 5. No queries — can't filter/sort, must load everything
// IndexedDB fixes all of this:
// - Async — never blocks the main thread
// - GBs of storage (browser quota)
// - Structured data (Blobs, ArrayBuffers, objects)
// - Transactions — atomic reads/writes
// - Indexes — query by any field
Dexie.js
Dexie.js — the polished IndexedDB ORM:
Define your database schema
import Dexie, { type EntityTable } from "dexie"
// Define types:
interface Package {
id?: number // Auto-increment
name: string
version: string
weeklyDownloads: number
healthScore: number
tags: string[]
updatedAt: Date
}
interface Favorite {
id?: number
packageName: string
addedAt: Date
}
// Define database:
const db = new Dexie("PkgPulseDB") as Dexie & {
packages: EntityTable<Package, "id">
favorites: EntityTable<Favorite, "id">
}
db.version(1).stores({
packages: "++id, name, weeklyDownloads, healthScore, updatedAt",
// ^^^ ^^^ ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^
// auto-inc primary key indexed fields (queryable)
favorites: "++id, packageName, addedAt",
})
CRUD operations
// Add:
await db.packages.add({
name: "react",
version: "18.3.1",
weeklyDownloads: 25_000_000,
healthScore: 92,
tags: ["ui", "frontend"],
updatedAt: new Date(),
})
// Add or update:
await db.packages.put({ id: 1, name: "react", ...updatedData })
// Update specific fields:
await db.packages.update(1, { weeklyDownloads: 26_000_000 })
// Get by primary key:
const react = await db.packages.get(1)
// Delete:
await db.packages.delete(1)
// Bulk add (efficient for large imports):
await db.packages.bulkAdd(packageList)
Queries
// Get all:
const all = await db.packages.toArray()
// Filter (uses index if available):
const popular = await db.packages
.where("weeklyDownloads")
.above(1_000_000)
.toArray()
// Multiple conditions:
const healthy = await db.packages
.where("healthScore")
.between(80, 100)
.and((pkg) => pkg.weeklyDownloads > 100_000)
.sortBy("weeklyDownloads")
// Text search (startsWith uses index):
const reactPackages = await db.packages
.where("name")
.startsWith("react")
.toArray()
// Compound index:
db.version(2).stores({
packages: "++id, name, weeklyDownloads, healthScore, [name+version]",
// ^^^^^^^^^^^^^^
// compound index for exact match
})
const exact = await db.packages
.where(["name", "version"])
.equals(["react", "18.3.1"])
.first()
// Count:
const count = await db.packages.count()
// Limit and offset:
const page2 = await db.packages
.orderBy("weeklyDownloads")
.reverse()
.offset(20)
.limit(20)
.toArray()
Transactions
// Atomic operations — either all succeed or all roll back:
await db.transaction("rw", db.packages, db.favorites, async () => {
const pkg = await db.packages.get(1)
if (!pkg) throw new Error("Package not found")
await db.favorites.add({
packageName: pkg.name,
addedAt: new Date(),
})
await db.packages.update(1, { favoritedCount: (pkg.favoritedCount ?? 0) + 1 })
// If either operation fails, both are rolled back automatically
})
React integration with useLiveQuery
import { useLiveQuery } from "dexie-react-hooks"
import { db } from "./db"
function PackageList({ minScore }: { minScore: number }) {
// Automatically re-renders when matching data changes in IndexedDB:
const packages = useLiveQuery(
() => db.packages
.where("healthScore")
.aboveOrEqual(minScore)
.sortBy("weeklyDownloads"),
[minScore] // Re-runs query when minScore changes
)
if (!packages) return <div>Loading...</div>
return (
<ul>
{packages.map((pkg) => (
<li key={pkg.id}>{pkg.name} — score: {pkg.healthScore}</li>
))}
</ul>
)
}
// When another tab/component adds a package to IndexedDB,
// all components using useLiveQuery automatically re-render — no state management needed!
Schema migrations
// Dexie handles IndexedDB version upgrades automatically:
const db = new Dexie("PkgPulseDB")
db.version(1).stores({
packages: "++id, name, weeklyDownloads",
})
db.version(2).stores({
packages: "++id, name, weeklyDownloads, healthScore", // Added healthScore index
})
db.version(3)
.stores({
packages: "++id, name, weeklyDownloads, healthScore, ecosystem",
ecosystems: "++id, name", // New table
})
.upgrade(async (tx) => {
// Data migration — called when upgrading from v2 to v3:
await tx.packages.toCollection().modify((pkg) => {
pkg.ecosystem = pkg.name.startsWith("@") ? "scoped" : "public"
})
})
localForage
localForage — simple offline storage:
Basic usage
import localforage from "localforage"
// Simple key-value store (localStorage API, IndexedDB backend):
await localforage.setItem("user", { id: 1, name: "Royce", settings: { theme: "dark" } })
const user = await localforage.getItem<User>("user")
// Remove and clear:
await localforage.removeItem("user")
await localforage.clear()
// List keys:
const keys = await localforage.keys()
// Get length:
const count = await localforage.length()
Configuration
import localforage from "localforage"
// Configure storage backend priority:
localforage.config({
driver: [
localforage.INDEXEDDB, // Prefer IndexedDB (async, large)
localforage.WEBSQL, // Fall back to WebSQL (deprecated but some older Safari)
localforage.LOCALSTORAGE, // Fall back to localStorage (sync, ~5MB)
],
name: "PkgPulse", // Database name
storeName: "user_data", // Object store name
version: 1.0,
description: "PkgPulse user data cache",
})
Multiple stores
import localforage from "localforage"
// Create separate stores for different data types:
const packageCache = localforage.createInstance({
name: "PkgPulse",
storeName: "packages",
})
const userPrefs = localforage.createInstance({
name: "PkgPulse",
storeName: "preferences",
})
// Use independently:
await packageCache.setItem("react", { version: "18.3.1", downloads: 25_000_000 })
await userPrefs.setItem("theme", "dark")
const theme = await userPrefs.getItem<string>("theme") // "dark"
Iterate over all items
// localForage supports iterating:
await localforage.iterate<Package, void>((pkg, key, iteration) => {
console.log(key, pkg)
// Return non-undefined to stop iteration early:
if (iteration === 5) return // Stop after 5
})
idb
idb — thin IndexedDB wrapper by Jake Archibald:
Open database
import { openDB, deleteDB, wrap, unwrap } from "idb"
// Open (or create) database:
const db = await openDB("PkgPulseDB", 1, {
upgrade(db, oldVersion, newVersion, transaction) {
// Called when version upgrades (or first creation):
if (oldVersion < 1) {
const packageStore = db.createObjectStore("packages", {
keyPath: "id",
autoIncrement: true,
})
packageStore.createIndex("name", "name")
packageStore.createIndex("healthScore", "healthScore")
}
},
blocked() {
// Called if the upgrade is blocked by another open tab
alert("Please close other tabs to upgrade the database.")
},
blocking() {
// Called if this version is blocking a newer version
db.close()
},
})
CRUD
// Add:
const id = await db.add("packages", {
name: "react",
version: "18.3.1",
weeklyDownloads: 25_000_000,
healthScore: 92,
})
// Get:
const react = await db.get("packages", id)
// Put (upsert):
await db.put("packages", { id, name: "react", weeklyDownloads: 26_000_000 })
// Delete:
await db.delete("packages", id)
// Get all:
const all = await db.getAll("packages")
// Get all from index:
const healthy = await db.getAllFromIndex("packages", "healthScore", IDBKeyRange.lowerBound(80))
Transactions
// Explicit transaction:
const tx = db.transaction(["packages", "favorites"], "readwrite")
const pkg = await tx.objectStore("packages").get(1)
await tx.objectStore("favorites").add({ packageId: 1, addedAt: new Date() })
await tx.done // Wait for transaction to complete
Cursor (for large datasets)
// Iterate over many records without loading all into memory:
const tx = db.transaction("packages", "readonly")
let cursor = await tx.store.openCursor()
while (cursor) {
const pkg: Package = cursor.value
console.log(pkg.name)
cursor = await cursor.continue()
}
Feature Comparison
| Feature | Dexie.js | localForage | idb |
|---|---|---|---|
| API style | ORM-like | key-value | Raw IndexedDB (promisified) |
| Query/filtering | ✅ Rich queries | ❌ Get by key only | ✅ (via IDBKeyRange) |
| React hooks | ✅ useLiveQuery | ❌ | ❌ |
| Schema migrations | ✅ Built-in | ❌ | ✅ Manual |
| localStorage fallback | ❌ | ✅ | ❌ |
| TypeScript | ✅ Excellent | ✅ Good | ✅ Good |
| Bundle size | ~65KB | ~8KB | ~3KB |
| Transactions | ✅ Simplified | ❌ | ✅ Full control |
| Live queries | ✅ | ❌ | ❌ |
| Bulk operations | ✅ bulkAdd/bulkPut | ❌ | ✅ |
| Weekly downloads | ~3M | ~5M | ~3M |
When to Use Each
Choose Dexie.js if:
- Building an offline-first app with real data requirements
- Using React and want reactive UI from IndexedDB changes (
useLiveQuery) - Need rich queries: range queries, compound indexes, sorting, pagination
- Working with relational-ish data (packages → dependencies → maintainers)
Choose localForage if:
- Simple key-value caching (last visited page, user settings, cached API responses)
- Need localStorage-like API but with async and larger capacity
- Apps that need to run in environments with inconsistent IndexedDB support (old mobile browsers)
- Replacing
localStorage.setItem/getItemwithout learning IndexedDB
Choose idb if:
- You know IndexedDB well and want minimal abstraction overhead
- Building a library or tool that should expose raw IndexedDB capabilities
- Need precise control over transactions, cursors, and object store configuration
- Bundle size is critical — 3KB vs Dexie's 65KB
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on dexie v4.x, localforage v1.x, and idb v8.x.