IndexedDB
When localStorage is too small or too synchronous, the browser has a real database built in: IndexedDB. It is an asynchronous, transactional, key/value (with indexes) store that can hold hundreds of MB per origin and stores structured data — including Date, Blob, File, ArrayBuffer and Map — directly, without JSON serialisation. The native API is verbose; almost everyone uses a wrapper like idb.
What IndexedDB is good for
Offline-capable apps that need to keep data when the network is down.
Caching API responses larger than a few hundred KB.
Storing user-generated files — images, audio, attachments — as Blobs.
Local search indexes for documents, notes, mail.
Sync queues — actions to replay against the server when back online.
The mental model
Database — named, versioned, per-origin.
Object store — like a table, but holds JS objects keyed by a
keyPathor auto-incremented key.Index — a secondary lookup on a property of stored objects.
Transaction — every read/write happens inside a transaction; transactions auto-commit when no more requests are pending.
Opening a database
Open / upgrade
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open("notes-app", 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
// Only runs when the version number is new (or on first open):
const store = db.createObjectStore("notes", {
keyPath: "id",
autoIncrement: true,
});
store.createIndex("by_tag", "tag", { unique: false });
store.createIndex("by_updated", "updatedAt");
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}Bumping the second argument to indexedDB.open triggers onupgradeneeded again — this is how you migrate schemas.
Reading and writing
put / get / delete
function put(db, value) {
return new Promise((resolve, reject) => {
const tx = db.transaction("notes", "readwrite");
const req = tx.objectStore("notes").put(value);
req.onsuccess = () => resolve(req.result); // the key
tx.onerror = () => reject(tx.error);
});
}
function get(db, id) {
return new Promise((resolve, reject) => {
const req = db
.transaction("notes", "readonly")
.objectStore("notes")
.get(id);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function remove(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction("notes", "readwrite");
tx.objectStore("notes").delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// Usage:
const db = await openDB();
const id = await put(db, { title: "Hello", tag: "personal", updatedAt: Date.now() });
const note = await get(db, id);
await remove(db, id);Listing with a cursor
function listByTag(db, tag) {
return new Promise((resolve, reject) => {
const results = [];
const tx = db.transaction("notes", "readonly");
const index = tx.objectStore("notes").index("by_tag");
const req = index.openCursor(IDBKeyRange.only(tag));
req.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
req.onerror = () => reject(req.error);
});
}The idb wrapper
The native API is event-based; promisifying it as above is repetitive. The tiny idb package by Jake Archibald gives you a clean async/await surface.
With idb
import { openDB } from "idb";
const db = await openDB("notes-app", 1, {
upgrade(db) {
const store = db.createObjectStore("notes", {
keyPath: "id",
autoIncrement: true,
});
store.createIndex("by_tag", "tag");
},
});
const id = await db.put("notes", { title: "Hello", tag: "personal" });
const note = await db.get("notes", id);
const personal = await db.getAllFromIndex("notes", "by_tag", "personal");
await db.delete("notes", id);Storage quota
if (navigator.storage?.estimate) {
const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1e6).toFixed(1)} MB of ${(quota / 1e6).toFixed(0)} MB`);
}
// Ask the browser to make the data persistent (i.e. not auto-evicted):
const persisted = await navigator.storage.persist();Chromium gives apps a generous share of disk (often tens of GB). Safari is much stricter and evicts after a few days of inactivity unless you call navigator.storage.persist() and the browser grants it.
Things to remember
Transactions auto-commit when the JS stack drains — do not
awaitnon-IndexedDB work inside a transaction or it will close on you.All keys must be strings, numbers, dates, or arrays of them. They are not arbitrary objects.
Cross-origin scripts cannot see your IndexedDB — it is origin-scoped.
Private/incognito mode often disables IndexedDB persistence or gives a much smaller quota.
Big writes should be batched into one transaction for atomicity and speed.