Skip to main content

Guide

Dexie.js vs localForage vs idb 2026

Dexie.js, localForage, and idb compared for IndexedDB browser storage in 2026. Offline-first, live queries, schema migrations, React hooks, and which to choose.

·PkgPulse Team·
0

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

Offline-First Architecture Patterns

Choosing the right IndexedDB library is partly a question of how seriously your application needs to take offline support. A fully offline-first application — one that works completely without a network and syncs when connectivity returns — has different requirements than an app that simply caches API responses to reduce load times.

Dexie.js is designed for the former scenario. Its useLiveQuery hook makes IndexedDB the single source of truth for React rendering: the component renders from local data, the background sync process writes updates to IndexedDB, and the component re-renders automatically via the live query subscription. This architecture works the same whether the device is online or offline, which is the fundamental property you need for genuine offline-first behavior.

The Dexie Cloud service extends this further with a managed conflict resolution and sync protocol, but even using Dexie locally without cloud sync, the reactive model is the right foundation. When a service worker intercepts a network request and writes the response to IndexedDB, any open component using useLiveQuery on that data store will reflect the update without any explicit state management code. This eliminates the "how do I get data from the service worker to my React component" problem that offline-first apps frequently struggle with.

localForage is appropriate for the simpler use case: caching data that improves performance or UX but is not authoritative. Caching the user's recent searches, storing the last-viewed page state so navigation feels instant, or holding API responses that hydrate the initial render before a fresh fetch completes — these are the scenarios where localForage's simple key-value API is a natural fit without overengineering.

idb is appropriate when you are building an abstraction layer yourself — writing a library, building a sync engine, or implementing a specific IndexedDB feature (cursors, complex transactions) that Dexie's higher-level API doesn't expose directly.

Schema Versioning and Long-Term Maintenance

IndexedDB database upgrades are one of the most error-prone aspects of client-side storage, and the three libraries differ significantly in how they handle the schema evolution problem.

With idb, every schema change requires you to write a manual upgrade function in the openDB callback, checking oldVersion to determine which migrations to apply. This is correct and gives you full control, but it is easy to write incorrect version checks — especially when multiple migrations accumulate over time. A missing if (oldVersion < N) guard causes migration code to run again for users who already have the updated schema, corrupting data.

Dexie's versioning model is more resilient. You declare each version sequentially, and Dexie applies upgrade functions only for the delta between the user's current version and the latest:

db.version(1).stores({ packages: "++id, name" })
db.version(2).stores({ packages: "++id, name, weeklyDownloads" })  // adds index
db.version(3).stores({ packages: "++id, name, weeklyDownloads, healthScore" })
  .upgrade(tx => tx.packages.toCollection().modify(pkg => { pkg.healthScore = 0 }))

A user upgrading from version 1 to version 3 will have the version 3 migration applied. A user already on version 2 will only have the version 3 migration applied. The sequential declaration makes the history readable and the migration logic correct without manual version comparison guards.

localForage does not have a schema migration concept because it has no schema — it is a key-value store, so there are no indexes to add or object stores to restructure. If you need to change the shape of stored values, you do it application-side by reading, transforming, and rewriting each key. This works but is cumbersome at scale, which is why localForage is best suited for use cases where data shape stability is high (user preferences, simple cache entries).

Storage Quotas and Browser Limits

Understanding IndexedDB storage quotas is important before committing to client-side storage for large datasets. Browsers do not provide unlimited storage — they allocate based on available disk space and browser-specific policies.

In modern browsers (Chrome, Edge, Firefox, Safari), IndexedDB storage is governed by the Storage API's "best-effort" and "persistent" storage modes. In best-effort mode (the default), the browser may evict IndexedDB data under storage pressure — when the device is running low on disk space. The eviction order is least-recently-used, so active users retain their data longer, but there are no guarantees. For production applications, request persistent storage with navigator.storage.persist(), which asks the browser to not evict your data. Chrome grants this automatically for sites the user has installed as PWAs or bookmarked; for other origins, it prompts the user.

Typical quota allocations: Chrome grants up to 60% of available disk space per origin with a minimum of 6GB. Firefox follows similar rules. Safari on iOS historically limited storage to 50MB per origin, but Safari 16.4 increased this dramatically — sites can now request up to the device's available space (with user permission) for persistent storage. The old 50MB Safari limitation that drove many mobile web developers toward smaller solutions is no longer the binding constraint it once was.

None of the three libraries — Dexie.js, localForage, or idb — control quota directly; they all go through the browser's IndexedDB implementation. Dexie provides a getDatabaseNames() utility that can help estimate current usage. For critical offline-first applications, pair any of these libraries with navigator.storage.estimate() to monitor remaining quota and warn users before they hit the limit.

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 →

See also: AVA vs Jest and ohash vs object-hash vs hash-wasm, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.