// apis · Web Platform Advent #23
What is IndexedDB? The browser database explained
IndexedDB is the browser's real database - asynchronous, transactional and far bigger than localStorage. How onupgradeneeded works, why transactions close on you, what indexes are for, and when to use it instead of Web Storage.
IndexedDB is the browser's real database. Where Web Storage gives you a synchronous box of strings capped around 5 MB, IndexedDB gives you an asynchronous, transactional store for structured data, sized in hundreds of megabytes or more. It is not SQL. It is a key-value store with indexes, and once that clicks the rest of the API follows.
Opening a database, and the only place you can change its shape
You open a database by name and version number. If the version you ask for is higher than the one on disk, the browser fires upgradeneeded, and that event handler is the only place where you may create or delete object stores and indexes.
const request = indexedDB.open('notes-db', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true });
store.createIndex('by_tag', 'tag', { unique: false });
};
request.onsuccess = (event) => {
const db = event.target.result;
};
request.onerror = () => console.error(request.error); This catches people out because it inverts the usual habit. You cannot create a store lazily the first time you need it. The schema lives in one function, keyed to a version number you bump when the shape changes.
Everything is a request, and requests are asynchronous
Almost every IndexedDB call returns an IDBRequest rather than a value. You read the result in onsuccess and handle failure in onerror. Nothing blocks the main thread, which is the whole reason to prefer it over localStorage for anything sizeable.
Reads and writes happen inside a transaction
You cannot touch a store directly. You open a transaction over one or more stores, in readonly or readwrite mode, and work through it.
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
store.add({ title: 'Buy milk', tag: 'errand', done: false });
tx.oncomplete = () => console.log('written');
tx.onerror = () => console.error(tx.error); Here is the trap worth knowing in advance. A transaction commits automatically once there is no more pending work for it. If you await something unrelated in the middle of one, such as a fetch, the transaction closes while you wait and the next call throws TransactionInactiveError. Do the outside work first, then open the transaction.
Indexes, which are the point of the name
Records are fetched by primary key by default. An index is a second ordering over the same records so you can query by another property.
const tx = db.transaction('notes', 'readonly');
const index = tx.objectStore('notes').index('by_tag');
const req = index.getAll('errand');
req.onsuccess = () => console.log(req.result); For ranges rather than exact matches, combine an index with IDBKeyRange and a cursor, which walks results one at a time instead of loading them all into memory.
What it can store, and how much
IndexedDB accepts anything the structured clone algorithm handles: objects, arrays, Date, Blob, File, ArrayBuffer. Functions and DOM nodes are not clonable and will throw. No JSON.stringify round trip is needed, which also means numbers stay numbers and dates stay dates.
The quota is a share of available disk rather than a fixed 5 MB, so it is the right home for cached API responses, offline documents or media. That storage is best-effort by default and can be evicted under disk pressure; call navigator.storage.persist() if the data must survive that, and check navigator.storage.estimate() to see what you are using.
IndexedDB or localStorage?
| localStorage | IndexedDB | |
|---|---|---|
| API style | Synchronous, blocks the main thread | Asynchronous, event based |
| Values | Strings only | Structured clonable values |
| Rough size | About 5 MB per origin | A share of free disk space |
| Querying | By key only | By key, by index, by range with cursors |
| Good for | Theme, flags, small preferences | Offline data, caches, files, large lists |
Both are scoped to the origin and neither is a place for secrets: any script on the page can read them.
Should you use a wrapper?
The raw API is verbose because it predates promises. Small helper libraries wrap requests in promises and remove most of the boilerplate, and they are a reasonable default for application code. Learn the underlying model first anyway: upgradeneeded, transactions and their auto-commit behave the same way underneath, and every confusing bug you will hit comes from those three, not from the wrapper.
Reach for IndexedDB when data is structured, large, or needs to be there offline, often alongside a service worker. Keep localStorage for the handful of small flags where a synchronous read is genuinely simpler.