A tiny promise-based wrapper around IndexedDB
Published February 9, 2027
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("app-cache", 1);
request.onupgradeneeded = () => request.result.createObjectStore("kv");
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const runTransaction = async <T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>) => {
const db = await dbPromise;
const store = db.transaction("kv", mode).objectStore("kv");
return new Promise<T>((resolve, reject) => {
const request = fn(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};
export const idbGet = <T>(key: string) => runTransaction<T>("readonly", (store) => store.get(key));
export const idbSet = (key: string, value: unknown) =>
runTransaction("readwrite", (store) => store.put(value, key)).then(() => undefined);What
idbGet and idbSet wrap IndexedDB's open-database handshake and its IDBRequest event pattern in promises, so reading and writing a value looks like await idbGet(key) instead of a page of onsuccess/onerror handlers.
Why it matters
IndexedDB predates promises. Every operation — opening the database, running a transaction, reading a record — returns an object that fires onsuccess or onerror, not something you can await. Used directly, that means nested callbacks for anything beyond a single read, and it's easy to forget an onerror handler on one of the several request objects involved, which turns a real failure (quota exceeded, blocked by another tab holding an upgrade) into an operation that silently never resolves.
localStorage avoids all this ceremony but is synchronous, string-only, and blocks the main thread on every access — fine for a few small flags, wrong for anything larger or async by nature. IndexedDB is the only broadly supported storage that's async, handles structured data and large payloads, and survives a page reload — it just needs a thin promise layer to be pleasant to use.
How it works
dbPromiseopens the database once, at module load, and is awaited by every subsequent call rather than re-opened each time.indexedDB.open()returns anIDBOpenDBRequest, not a promise, so it's wrapped exactly once here.onupgradeneededfires only the first time the database is created (or when the version number increases) — this is the one correct place to callcreateObjectStore, since calling it outside an upgrade transaction throws.runTransactionis the shared plumbing: it awaits the open database, starts a transaction in the givenmode, hands the object store tofn, and wraps whateverIDBRequestfnreturns in a second promise. BothidbGetandidbSetare one-line calls into it, differing only in which store method they call and which transaction mode they need.idbSetdoesn't pass an explicit type argument torunTransaction— TypeScript infers it fromstore.put()'s real return type (IDBRequest<IDBValidKey>), and the trailing.then(() => undefined)throws that resolved key away so the function's public shape is a cleanPromise<void>.
Gotchas
- Every operation here needs two error handlers in the untrimmed version — one on the open request, one on the per-operation request — because either layer can fail independently. This snippet is 18 lines against the repo's 6-11 target for exactly that reason (
lineWaiver: true): dropping eitheronerrorhandler would leave a failure mode that hangs forever instead of rejecting. onupgradeneededonly fires on version changes. If you need to change the object store's shape later (add an index, rename a store), bump the version number passed toindexedDB.open()— reusing the same version with different upgrade logic does nothing.- IndexedDB transactions auto-commit once there's no more work queued on them in the current microtask/macrotask — don't
awaitsomething unrelated (like afetch) in the middle of a transaction, or it'll close before your next request runs.
Related
- The Cache API — a simpler, purpose-built store for HTTP
Request/Responsepairs; reach for it instead of IndexedDB when what you're caching is literally a network response. localStorage— fine for small synchronous flags; wrong tool once data is structured, large, or read/written from a non-blocking context.