dexie, idb, and localforage are JavaScript libraries designed to simplify client-side data persistence in web applications. dexie is a feature-rich wrapper for IndexedDB that offers a fluent API and schema management. idb is a lightweight, promise-based wrapper that exposes native IndexedDB functionality with minimal abstraction. localforage provides a simple key-value API that works across IndexedDB, WebSQL, and localStorage, abstracting the underlying storage engine completely.
When building offline-first web apps or caching layers, choosing the right storage library is critical. dexie, idb, and localforage all target client-side persistence, but they solve different problems with different levels of abstraction. Let's compare how they handle schema, queries, and transactions.
dexie uses a fluent schema definition that feels like an ORM.
// dexie: Declarative schema
import Dexie from 'dexie';
const db = new Dexie('MyDatabase');
db.version(1).stores({
friends: '++id, name, age' // id is auto-inc, name and age are indexed
});
idb exposes the native IndexedDB upgrade process via promises.
upgrade callback manually.// idb: Manual upgrade block
import { openDB } from 'idb';
const db = await openDB('MyDatabase', 1, {
upgrade(db) {
const store = db.createObjectStore('friends', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('by_age', 'age');
}
});
localforage has no schema.
localStorage.// localforage: No schema, just config
import localforage from 'localforage';
localforage.config({
name: 'MyDatabase',
version: 1,
storeName: 'friends' // Acts like a single bucket
});
// Data is stored as key-value pairs only
dexie allows complex queries using a chainable API.
// dexie: Rich querying
const adults = await db.friends
.where('age')
.above(18)
.and(friend => friend.name.startsWith('A'))
.toArray();
idb uses native IndexedDB queries via promises.
IDBKeyRange for advanced filtering.// idb: Native querying
import { IDBKeyRange } from 'idb';
const range = IDBKeyRange.lowerBound(18);
const adults = await db.getAllFromIndex('friends', 'by_age', range);
// Further filtering must be done manually in JS
localforage does not support value-based queries.
// localforage: Key-value only
const friend = await localforage.getItem('friend_123');
// To find by value, you must iterate everything (slow)
const keys = await localforage.keys();
// Manual filtering required in application code
dexie handles transactions automatically or explicitly.
// dexie: Explicit transaction
await db.transaction('rw', db.friends, db.logs, async () => {
await db.friends.add({ name: 'Alice' });
await db.logs.add({ action: 'added_friend' });
});
idb requires manual transaction creation.
// idb: Manual transaction
const tx = db.transaction('friends', 'readwrite');
await tx.objectStore('friends').add({ name: 'Alice' });
await tx.done; // Waits for completion
localforage does not support multi-key transactions.
setItem calls succeed or fail together.// localforage: No transactions
await localforage.setItem('key1', 'value1');
await localforage.setItem('key2', 'value2');
// If the second fails, the first is already saved
dexie is actively maintained with a large community.
idb is maintained by Google engineers (Jake Archibald).
localforage is in maintenance mode with low activity.
| Feature | dexie | idb | localforage |
|---|---|---|---|
| API Style | π’ Fluent, ORM-like | π‘ Promise-based Native | π Key-Value (localStorage) |
| Schema | β Declarative & Versioned | β Manual Upgrade Block | β None |
| Querying | β Rich (Ranges, Filters) | β Native (IDBKeyRange) | β Keys Only |
| Transactions | β Multi-store Atomic | β Manual Control | β Per-Operation Only |
| Bundle Weight | π Medium | π’ Tiny | π Medium |
| Status | π’ Active | π’ Active | π‘ Maintenance |
dexie is the productivity choice π§°. It removes the pain of IndexedDB while keeping its power. Use it for complex apps like note-takers, caches, or offline sync engines where you need to query data efficiently.
idb is the purist choice π§. It gives you promises without hiding the engine. Use it if you want standards compliance, minimal dependencies, and you already understand how IndexedDB works.
localforage is the legacy choice π°οΈ. It is great for dropping into old projects that need better storage than localStorage without refactoring logic. For new projects, prefer dexie or idb to avoid hitting the key-value ceiling later.
Final Thought: IndexedDB is powerful but verbose. dexie makes it enjoyable, idb makes it modern, and localforage makes it simple β but too simple for serious data work. Choose based on how much querying you need to do.
Choose idb if you want full control over IndexedDB with modern promise-based syntax but minimal abstraction overhead. It is best for developers who understand IndexedDB concepts and want a tiny, standards-compliant helper that doesn't hide the underlying API mechanics.
Choose localforage only for simple key-value storage needs where you want a localStorage-like API but with better performance and capacity. Avoid it for complex queries or new projects requiring advanced IndexedDB features, as it abstracts away the power of IndexedDB and is in maintenance mode.
Choose dexie if you need complex querying, schema versioning, and a developer-friendly API for IndexedDB. It is ideal for applications that treat the browser database like a real backend, requiring indexes, relationships, and transactional safety without writing boilerplate code.
This is a tiny (~1.19kB brotli'd) library that mostly mirrors the IndexedDB API, but with small improvements that make a big difference to usability.
npm install idb
Then, assuming you're using a module-compatible system (like webpack, Rollup etc):
import { openDB, deleteDB, wrap, unwrap } from 'idb';
async function doDatabaseStuff() {
const db = await openDB(β¦);
}
<script type="module">
import { openDB, deleteDB, wrap, unwrap } from 'https://cdn.jsdelivr.net/npm/idb@8/+esm';
async function doDatabaseStuff() {
const db = await openDB(β¦);
}
</script>
<script src="https://cdn.jsdelivr.net/npm/idb@8/build/umd.js"></script>
<script>
async function doDatabaseStuff() {
const db = await idb.openDB(β¦);
}
</script>
A global, idb, will be created, containing all exports of the module version.
See details of (potentially) breaking changes.
This library targets modern browsers, as in Chrome, Firefox, Safari, and other browsers that use those engines, such as Edge. IE is not supported.
openDBThis method opens a database, and returns a promise for an enhanced IDBDatabase.
const db = await openDB(name, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
// β¦
},
blocked(currentVersion, blockedVersion, event) {
// β¦
},
blocking(currentVersion, blockedVersion, event) {
// β¦
},
terminated() {
// β¦
},
});
name: Name of the database.version (optional): Schema version, or undefined to open the current version.upgrade (optional): Called if this version of the database has never been opened before. Use it to specify the schema for the database. This is similar to the upgradeneeded event in plain IndexedDB.
db: An enhanced IDBDatabase.oldVersion: Last version of the database opened by the user.newVersion: Whatever new version you provided.transaction: An enhanced transaction for this upgrade. This is useful if you need to get data from other stores as part of a migration.event: The event object for the associated upgradeneeded event.blocked (optional): Called if there are older versions of the database open on the origin, so this version cannot open. This is similar to the blocked event in plain IndexedDB.
currentVersion: Version of the database that's blocking this one.blockedVersion: The version of the database being blocked (whatever version you provided to openDB).event: The event object for the associated blocked event.blocking (optional): Called if this connection is blocking a future version of the database from opening. This is similar to the versionchange event in plain IndexedDB.
currentVersion: Version of the open database (whatever version you provided to openDB).blockedVersion: The version of the database that's being blocked.event: The event object for the associated versionchange event.terminated (optional): Called if the browser abnormally terminates the connection, but not on regular closures like calling db.close(). This is similar to the close event in plain IndexedDB.deleteDBDeletes a database.
await deleteDB(name, {
blocked() {
// β¦
},
});
name: Name of the database.blocked (optional): Called if the database already exists and there are open connections that donβt close in response to a versionchange event, the request will be blocked until they all close.
currentVersion: Version of the database that's blocking the delete operation.event: The event object for the associated 'versionchange' event.unwrapTakes an enhanced IndexedDB object and returns the plain unmodified one.
const unwrapped = unwrap(wrapped);
This is useful if, for some reason, you want to drop back into plain IndexedDB. Promises will also be converted back into IDBRequest objects.
wrapTakes an IDB object and returns a version enhanced by this library.
const wrapped = wrap(unwrapped);
This is useful if some third party code gives you an IDBDatabase object and you want it to have the features of this library.
Once you've opened the database the API is the same as IndexedDB, except for a few changes to make things easier.
Firstly, any method that usually returns an IDBRequest object will now return a promise for the result.
const store = db.transaction(storeName).objectStore(storeName);
const value = await store.get(key);
The library turns all IDBRequest objects into promises, but it doesn't know in advance which methods may return promises.
As a result, methods such as store.put may throw instead of returning a promise.
If you're using async functions, there's no observable difference.
TL;DR: Do not await other things between the start and end of your transaction, otherwise the transaction will close before you're done.
An IDB transaction auto-closes if it doesn't have anything left do once microtasks have been processed. As a result, this works fine:
const tx = db.transaction('keyval', 'readwrite');
const store = tx.objectStore('keyval');
const val = (await store.get('counter')) || 0;
await store.put(val + 1, 'counter');
await tx.done;
But this doesn't:
const tx = db.transaction('keyval', 'readwrite');
const store = tx.objectStore('keyval');
const val = (await store.get('counter')) || 0;
// This is where things go wrong:
const newVal = await fetch('/increment?val=' + val);
// And this throws an error:
await store.put(newVal, 'counter');
await tx.done;
In this case, the transaction closes while the browser is fetching, so store.put fails.
IDBDatabase enhancementsIt's common to create a transaction for a single action, so helper methods are included for this:
// Get a value from a store:
const value = await db.get(storeName, key);
// Set a value in a store:
await db.put(storeName, value, key);
The shortcuts are: get, getKey, getAll, getAllKeys, count, put, add, delete, and clear. Each method takes a storeName argument, the name of the object store, and the rest of the arguments are the same as the equivalent IDBObjectStore method.
The shortcuts are: getFromIndex, getKeyFromIndex, getAllFromIndex, getAllKeysFromIndex, and countFromIndex.
// Get a value from an index:
const value = await db.getFromIndex(storeName, indexName, key);
Each method takes storeName and indexName arguments, followed by the rest of the arguments from the equivalent IDBIndex method.
IDBTransaction enhancementstx.storeIf a transaction involves a single store, the store property will reference that store.
const tx = db.transaction('whatever');
const store = tx.store;
If a transaction involves multiple stores, tx.store is undefined, you need to use tx.objectStore(storeName) to get the stores.
tx.doneTransactions have a .done promise which resolves when the transaction completes successfully, and otherwise rejects with the transaction error.
const tx = db.transaction(storeName, 'readwrite');
await Promise.all([
tx.store.put('bar', 'foo'),
tx.store.put('world', 'hello'),
tx.done,
]);
If you're writing to the database, tx.done is the signal that everything was successfully committed to the database. However, it's still beneficial to await the individual operations, as you'll see the error that caused the transaction to fail.
IDBCursor enhancementsCursor advance methods (advance, continue, continuePrimaryKey) return a promise for the cursor, or null if there are no further values to provide.
let cursor = await db.transaction(storeName).store.openCursor();
while (cursor) {
console.log(cursor.key, cursor.value);
cursor = await cursor.continue();
}
You can iterate over stores, indexes, and cursors:
const tx = db.transaction(storeName);
for await (const cursor of tx.store) {
// β¦
}
Each yielded object is an IDBCursor. You can optionally use the advance methods to skip items (within an async iterator they return void):
const tx = db.transaction(storeName);
for await (const cursor of tx.store) {
console.log(cursor.value);
// Skip the next item
cursor.advance(2);
}
If you don't manually advance the cursor, cursor.continue() is called for you.
Stores and indexes also have an iterate method which has the same signature as openCursor, but returns an async iterator:
const index = db.transaction('books').store.index('author');
for await (const cursor of index.iterate('Douglas Adams')) {
console.log(cursor.value);
}
This is very similar to localStorage, but async. If this is all you need, you may be interested in idb-keyval. You can always upgrade to this library later.
import { openDB } from 'idb';
const dbPromise = openDB('keyval-store', 1, {
upgrade(db) {
db.createObjectStore('keyval');
},
});
export async function get(key) {
return (await dbPromise).get('keyval', key);
}
export async function set(key, val) {
return (await dbPromise).put('keyval', val, key);
}
export async function del(key) {
return (await dbPromise).delete('keyval', key);
}
export async function clear() {
return (await dbPromise).clear('keyval');
}
export async function keys() {
return (await dbPromise).getAllKeys('keyval');
}
import { openDB } from 'idb/with-async-ittr.js';
async function demo() {
const db = await openDB('Articles', 1, {
upgrade(db) {
// Create a store of objects
const store = db.createObjectStore('articles', {
// The 'id' property of the object will be the key.
keyPath: 'id',
// If it isn't explicitly set, create a value by auto incrementing.
autoIncrement: true,
});
// Create an index on the 'date' property of the objects.
store.createIndex('date', 'date');
},
});
// Add an article:
await db.add('articles', {
title: 'Article 1',
date: new Date('2019-01-01'),
body: 'β¦',
});
// Add multiple articles in one transaction:
{
const tx = db.transaction('articles', 'readwrite');
await Promise.all([
tx.store.add({
title: 'Article 2',
date: new Date('2019-01-01'),
body: 'β¦',
}),
tx.store.add({
title: 'Article 3',
date: new Date('2019-01-02'),
body: 'β¦',
}),
tx.done,
]);
}
// Get all the articles in date order:
console.log(await db.getAllFromIndex('articles', 'date'));
// Add 'And, happy new year!' to all articles on 2019-01-01:
{
const tx = db.transaction('articles', 'readwrite');
const index = tx.store.index('date');
for await (const cursor of index.iterate(new Date('2019-01-01'))) {
const article = { ...cursor.value };
article.body += ' And, happy new year!';
cursor.update(article);
}
await tx.done;
}
}
This library is fully typed, and you can improve things by providing types for your database:
import { openDB, DBSchema } from 'idb';
interface MyDB extends DBSchema {
'favourite-number': {
key: string;
value: number;
};
products: {
value: {
name: string;
price: number;
productCode: string;
};
key: string;
indexes: { 'by-price': number };
};
}
async function demo() {
const db = await openDB<MyDB>('my-db', 1, {
upgrade(db) {
db.createObjectStore('favourite-number');
const productStore = db.createObjectStore('products', {
keyPath: 'productCode',
});
productStore.createIndex('by-price', 'price');
},
});
// This works
await db.put('favourite-number', 7, 'Jen');
// This fails at compile time, as the 'favourite-number' store expects a number.
await db.put('favourite-number', 'Twelve', 'Jake');
}
To define types for your database, extend DBSchema with an interface where the keys are the names of your object stores.
For each value, provide an object where value is the type of values within the store, and key is the type of keys within the store.
Optionally, indexes can contain a map of index names, to the type of key within that index.
Provide this interface when calling openDB, and from then on your database will be strongly typed. This also allows your IDE to autocomplete the names of stores and indexes.
If you call openDB without providing types, your database will use basic types. However, sometimes you'll need to interact with stores that aren't in your schema, perhaps during upgrades. In that case you can cast.
Let's say we were renaming the 'favourite-number' store to 'fave-nums':
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface MyDBV1 extends DBSchema {
'favourite-number': { key: string; value: number };
}
interface MyDBV2 extends DBSchema {
'fave-num': { key: string; value: number };
}
const db = await openDB<MyDBV2>('my-db', 2, {
async upgrade(db, oldVersion) {
// Cast a reference of the database to the old schema.
const v1Db = db as unknown as IDBPDatabase<MyDBV1>;
if (oldVersion < 1) {
v1Db.createObjectStore('favourite-number');
}
if (oldVersion < 2) {
const store = v1Db.createObjectStore('favourite-number');
store.name = 'fave-num';
}
},
});
You can also cast to a typeless database by omitting the type, eg db as IDBPDatabase.
Note: Types like IDBPDatabase are used by TypeScript only. The implementation uses proxies under the hood.
pnpm run dev
This will also perform type testing.
To test, navigate to build/test/ in a browser. You'll need to set up a basic web server for this.