IndexedDB is the most powerful client-side storage API browsers ship, and also one of the most painful to use directly — Dexie.js exists entirely to close that gap with a Promise-based API that feels like a normal database library instead of a callback-driven event system from 2010.
Dexie.js is a wrapper library around the browser's native IndexedDB API, replacing its verbose, callback/event-based interface with a clean, Promise-based API supporting complex queries, indexes, and transactions. IndexedDB itself provides genuinely capable client-side storage — larger capacity than localStorage, structured data, and transactional guarantees — but its raw API is widely considered painful enough that almost nobody uses it directly.
Why Dexie.js Matters (and When to Skip It)
Raw IndexedDB requires manually managing transactions, request objects, and onsuccess/onerror callbacks for every operation — a simple "get all records matching a filter" turns into a cursor-based loop with manual promise wrapping. Dexie collapses this into a query API that reads like a normal database library, while still using IndexedDB under the hood for real persistent, structured client-side storage.
Skip Dexie/IndexedDB entirely if your client-side storage needs are simple key-value pairs well under a few megabytes — localStorage (or a simple wrapper around it) is sufficient and simpler for that case. Reach for Dexie specifically when you need structured queries, larger storage capacity, or offline-first data sync.
Getting Started with Dexie.js
import Dexie, { type Table } from "dexie";
interface Post {
id?: number;
title: string;
tags: string[];
createdAt: Date;
}
class AppDatabase extends Dexie {
posts!: Table<Post>;
constructor() {
super("AppDatabase");
this.version(1).stores({
posts: "++id, title, *tags, createdAt",
});
}
}
const db = new AppDatabase();
await db.posts.add({ title: "Offline-First Apps", tags: ["pwa", "offline"], createdAt: new Date() });
const recentPosts = await db.posts.where("createdAt").above(lastWeek).toArray();
Core Dexie.js Concepts Every Developer Should Know
Schema versioning handles migrations declaratively. Each version() call describes the index structure for that schema generation — Dexie handles upgrading existing user data through version bumps automatically when your app updates:
this.version(2).stores({
posts: "++id, title, *tags, createdAt, authorId", // added authorId index
}).upgrade((tx) => {
return tx.table("posts").toCollection().modify((post) => {
post.authorId = "unknown";
});
});
Compound and multi-entry indexes support real query patterns. The *tags syntax above creates a multi-entry index over an array field, letting you efficiently query "posts with this tag" without scanning every record:
const taggedPosts = await db.posts.where("tags").equals("offline").toArray();
liveQuery provides reactive queries, similar in spirit to Convex's reactive model but for client-side IndexedDB data — a query automatically re-runs and pushes updates when the underlying data changes:
import { liveQuery } from "dexie";
const observable = liveQuery(() => db.posts.orderBy("createdAt").reverse().toArray());
observable.subscribe((posts) => renderPosts(posts));
Transactions work across multiple tables with proper atomicity, the same conceptual guarantee as a server-side database transaction, but running entirely in the browser:
await db.transaction("rw", db.posts, db.authors, async () => {
await db.authors.add({ name: "Suhail" });
await db.posts.add({ title: "New Post", authorId: 1, tags: [], createdAt: new Date() });
});
Common Dexie.js Mistakes and How to Fix Them
Mistake 1: not handling schema version upgrades carefully. Shipping a new version() without a proper upgrade() function for existing users' data can leave their local database in an inconsistent state. Fix: always write and test upgrade functions when adding indexes or changing schema shape for an app already in users' hands.
Mistake 2: treating IndexedDB storage as unlimited. Browsers impose storage quotas (varying by browser and available disk space), and exceeding them causes write failures. Fix: handle storage quota errors gracefully, and use the Storage API (navigator.storage.estimate()) to check available space for data-heavy apps.
Mistake 3: not planning for sync conflicts in offline-first apps. Local IndexedDB data that diverges from a server while offline needs an explicit conflict resolution strategy when connectivity returns. Fix: design your sync logic (timestamps, version vectors, or last-write-wins with awareness of the tradeoff) deliberately rather than as an afterthought.
When Should You Use Dexie.js Instead of localStorage or a Server Round-Trip?
Use Dexie/IndexedDB when you need structured, queryable client-side data beyond simple key-value pairs, larger storage capacity, or genuine offline-first functionality (PWAs, apps that must work without connectivity). Use localStorage for small, simple key-value settings data, and a server round-trip when the data doesn't need to be available offline and server-side consistency matters more than local availability.
Dexie.js in Production
Test your offline-first sync logic under real network conditions (flaky connectivity, not just fully offline or fully online) — the edge cases around reconnection and conflict resolution are where most offline-first bugs actually live. Also monitor for storage quota issues in production, especially for data-heavy PWAs, since quota limits and eviction behavior vary meaningfully across browsers.
If you're building any kind of offline-capable web app and reaching for raw IndexedDB, save yourself the pain and use Dexie from the start — there's essentially no reason to hand-write IndexedDB's native callback API today.