Skip to main content

Dexie.js vs localForage vs idb: IndexedDB in the Browser (2026)

·PkgPulse Team

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 useLiveQuery makes React components automatically re-render when data changes
  • localForage falls 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

FeatureDexie.jslocalForageidb
API styleORM-likekey-valueRaw 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/getItem without 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.

Compare browser storage and utility packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.