What Is IndexedDB? Browser-Side Structured Storage
IndexedDB is a browser API for storing large amounts of structured data client-side, with indexes, transactions, and no size limit like localStorage.
IndexedDB is a browser-native database for storing structured data on the client — objects, files, and blobs — indexed for fast lookup and queried through transactions, all without a network round trip. Unlike localStorage, which only holds small string key-value pairs, IndexedDB can hold gigabytes of data and lets you query it efficiently instead of parsing a giant JSON blob on every read.
It’s the storage layer behind offline-capable web apps: mail clients that cache your inbox, note-taking apps that work on a plane, and any PWA that needs real data available before the network responds.
Object stores, not tables
IndexedDB is not relational. Instead of tables with fixed columns, it has object stores — schemaless containers of JavaScript objects, each identified by a key. You define object stores and their indexes during a versioned “upgrade” transaction, then read and write objects to them directly:
const request = indexedDB.open("notes-db", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore("notes", { keyPath: "id" });
store.createIndex("by-updated", "updatedAt");
};
Each object store can hold arbitrary JavaScript values — nested objects, arrays, Date instances, Blobs, even ArrayBuffers — because IndexedDB serializes values using the same algorithm as structuredClone, not JSON.stringify. That’s a meaningful difference: JSON can’t represent a Date or a Map without custom encoding, but IndexedDB stores them natively.
Transactions and versioning
Every read or write happens inside a transaction scoped to one or more object stores, with a mode of readonly or readwrite. Transactions auto-commit when there’s nothing left queued on them — there’s no explicit commit() call in most code, though one exists for edge cases. If any request inside the transaction fails, the whole transaction rolls back.
Schema changes go through a version number. Opening a database with a higher version than what’s stored fires onupgradeneeded, and that’s the only place you’re allowed to create or delete object stores and indexes. This makes schema migrations explicit and sequential, similar in spirit to how server-side database migrations version schema changes over time — except here the migration runs in every visiting browser, not once against a shared server.
Indexes and queries
Object stores support secondary indexes, so you can look up records by a field other than the primary key without scanning every object:
const tx = db.transaction("notes", "readonly");
const index = tx.objectStore("notes").index("by-updated");
const range = IDBKeyRange.lowerBound(Date.now() - 86400000);
const cursor = index.openCursor(range);
Cursors let you walk matching records in key order, which is how you implement pagination or “recently updated” views without pulling the entire store into memory. There’s no query language — no SQL-like WHERE clauses — just key ranges and cursors, which keeps the API low-level but predictable.
That low-level design is a deliberate tradeoff. A SQL engine can plan a query across arbitrary conditions on the fly; IndexedDB instead requires you to decide upfront which fields need an index, because a cursor can only walk one index at a time. Filtering on a field you didn’t index means opening a cursor over the whole store and checking each record in application code — which works, but scans linearly rather than jumping straight to matching keys the way a proper index lookup does.
IndexedDB vs the other browser storage options
| Capacity | Data types | Async | Queryable | |
|---|---|---|---|---|
| Cookies | ~4 KB | Strings | No | No |
localStorage | ~5-10 MB | Strings only | No (blocks the main thread) | No |
sessionStorage | ~5-10 MB | Strings only | No | No |
| IndexedDB | Gigabytes (quota-based) | Structured clone (objects, blobs, dates) | Yes | Yes, via indexes |
The practical takeaway from that table: localStorage is fine for small settings and flags, but its synchronous API blocks the main thread on every access and it can’t hold structured data without manual JSON encoding and decoding. IndexedDB is asynchronous and built for volume.
Working alongside service workers
IndexedDB is almost always paired with a service worker in offline-first apps: the service worker intercepts network requests and serves cached responses, while IndexedDB holds the structured application data those responses represent. A typical pattern is to write incoming data to IndexedDB as it arrives, then read from IndexedDB first on load and reconcile with the network in the background — the core idea behind local-first software.
Because IndexedDB operations are asynchronous, they’re also safe to call from inside a web worker, which is useful for moving heavy read/write batches off the main thread entirely.
The raw API vs a wrapper library
The native IndexedDB API is callback-based and verbose — onsuccess, onerror, and onupgradeneeded handlers everywhere. Most production code wraps it in a small promise-based layer (or uses a well-established wrapper library) rather than calling indexedDB.open directly on every page. The underlying concepts — object stores, versioned upgrades, transactions, cursors — stay the same either way; the wrapper just turns callbacks into await.
When to reach for it
IndexedDB earns its complexity when you need to store more than a few hundred kilobytes, need structured queries instead of parsing a blob, or need your app to function offline. For small key-value settings — a theme preference, a feature flag, a dismissed-banner flag — localStorage remains simpler and is not worth replacing. Reach for IndexedDB when the data itself is the point: cached API responses, a local copy of user content, or a queue of pending writes for a background sync.
The takeaway
IndexedDB trades the simplicity of localStorage for real database primitives in the browser: object stores instead of tables, versioned schema upgrades, transactions, and indexed queries over structured data. It’s asynchronous, holds far more than a few megabytes, and preserves real JavaScript types instead of forcing everything through JSON. Use it when an app needs to work offline or hold enough client-side data that scanning a JSON blob on every read stops being reasonable.
Tagged
Keep reading
Takina · · 5 min read Promise.all() vs allSettled() vs race() Compared
Promise.all() fails fast, allSettled() waits for every result, and race() returns whichever promise finishes first — how to choose correctly.
Takina · · 4 min read JavaScript Spread vs Rest Operators, Explained
The spread operator (...) expands an iterable into individual elements; the rest operator collects elements back into an array. Same syntax, opposite jobs.
Takina · · 4 min read ResizeObserver API Explained: Watching Element Size
The ResizeObserver API lets JavaScript watch an element's box size and react without polling or resize-event hacks. How it works and when to use it.