10LOC
Web Platformadvanced

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

  • dbPromise opens the database once, at module load, and is awaited by every subsequent call rather than re-opened each time. indexedDB.open() returns an IDBOpenDBRequest, not a promise, so it's wrapped exactly once here.
  • onupgradeneeded fires only the first time the database is created (or when the version number increases) — this is the one correct place to call createObjectStore, since calling it outside an upgrade transaction throws.
  • runTransaction is the shared plumbing: it awaits the open database, starts a transaction in the given mode, hands the object store to fn, and wraps whatever IDBRequest fn returns in a second promise. Both idbGet and idbSet are one-line calls into it, differing only in which store method they call and which transaction mode they need.
  • idbSet doesn't pass an explicit type argument to runTransaction — TypeScript infers it from store.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 clean Promise<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 either onerror handler would leave a failure mode that hangs forever instead of rejecting.
  • onupgradeneeded only 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 to indexedDB.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 await something unrelated (like a fetch) 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/Response pairs; 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.