IndexedDB
Reviewed & published by Brayan K
IndexedDB is a transactional, asynchronous database built into the browser that stores large amounts of structured data — objects, arrays, even files — keyed for fast lookup and queryable through indexes.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
It is what powers offline-capable web apps: caching API data, saving drafts, storing user-generated content, and keeping everything available when the network is not.
What You'll Learn in This Lesson
- What object stores and keys are
- How transactions group operations
- The request/event model
- Wrapping requests in promises
- Querying with indexes
- When to choose it over localStorage
💡 About the runnable demos: The real indexedDB global only exists in a browser, so it lives in the read-only code blocks. Each Try it Yourself runs a tiny promise-based key-value store backed by a Map that mirrors the same async put / get / getAll shape, so you see the real data flow in your console.
🏦 Real-World Analogy: localStorage is a sticky note on your monitor — quick, tiny, and only good for short text. IndexedDB is a filing-cabinet bank vault: drawers (object stores) of folders keyed by account number, indexes so you can also find a folder by name, and a teller who insists every set of changes happens as one atomic transaction — all or nothing.
1️⃣ Opening a Database & the Request Model
You open a database by name and version with indexedDB.open. The first time (or on a version bump) the onupgradeneeded event fires — the only place you may create object stores and indexes. Every operation returns an IDBRequest that later fires onsuccess or onerror; you read the value from request.result.
That callback-and-event dance is why everyone wraps a request in a promise. Here is the wrapper pattern running against a Map-backed store that behaves the same way:
// A fake request that fires onsuccess later, like IDBRequest
function fakeRequest(result) {
const req = { onsuccess: null, onerror: null, result: undefined };
setTimeout(() => {
req.result = result;
if (req.onsuccess) req.onsuccess({ target: req });
}, 0);
return req;
}
// The universal wrapper: turn an IDBRequest into a Promise
function promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
async function main() {
const value = await promisify(fakeRequest("hello from the store"));
console.log(value);
// ✅ Expected output: hello from the store
}
main();2️⃣ Transactions — put, get & getAll
All reads and writes happen inside a transaction. Open one with db.transaction(storeName, mode) where mode is "readonly" or "readwrite", grab the store with tx.objectStore(name), then call put, get, or getAll. Everything in the transaction commits together or rolls back together.
⚠️ Transactions are short-lived: a transaction auto-commits as soon as it has no more pending requests. Do not await unrelated work in the middle — the transaction may close out from under you.
The runnable store below implements put, get, and getAll with the same async signatures, backed by a Map:
// A Map-backed stand-in for an object store with keyPath "id"
function openStore() {
const data = new Map();
return {
put: (record) =>
new Promise((resolve) => {
data.set(record.id, record);
setTimeout(() => resolve(record.id), 0); // resolve with the key
}),
get: (id) =>
new Promise((resolve) => setTimeout(() => resolve(data.get(id)), 0)),
getAll: () =>
new Promise((resolve) => setTimeout(() => resolve([...data.values()]), 0)),
};
}
async function main() {
const store = openStore();
const key = await store.put({ id: 1, text: "Buy milk", tag: "todo" });
await store.put({ id: 2, text: "Call Sam", tag: "todo" });
console.log("inserted key:", key);
// ✅ Expected output: inserted key: 1
const one = await store.get(1);
console.log("got:", one.text);
// ✅ Expected output: got: Buy milk
const all = await store.getAll();
console.log("count:", all.length);
// ✅ Expected output: count: 2
}
main();🎯 Your Turn #1 — Read a Record Back
Replace the blank with await so the value resolves before you log it.
function openStore() {
const data = new Map();
return {
put: (r) => new Promise((res) => { data.set(r.id, r); res(r.id); }),
get: (id) => new Promise((res) => setTimeout(() => res(data.get(id)), 0)),
};
}
async function main() {
const store = openStore();
await store.put({ id: 7, text: "Ship it" });
// 👉 replace ___ with the keyword that waits for the promise
const note = ___ store.get(7);
console.log(note.text); // ✅ Expected output: Ship it
}
main();3️⃣ Indexes — Querying by Something Other Than the Key
By default you can only fetch by primary key. An index lets you query by another property. You declare it in onupgradeneeded with store.createIndex(name, keyPath), then read through it with store.index(name).getAll(value). Indexes can also enforce uniqueness.
The model below adds a simple secondary index so you can query by tag, not just by id:
function openStore() {
const data = new Map();
return {
put: (r) => new Promise((res) => { data.set(r.id, r); res(r.id); }),
// index lookup: every record whose [field] === value
index: (field, value) =>
new Promise((res) =>
setTimeout(
() => res([...data.values()].filter((r) => r[field] === value)),
0
)
),
};
}
async function main() {
const store = openStore();
await store.put({ id: 1, text: "Buy milk", tag: "todo" });
await store.put({ id: 2, text: "Read book", tag: "done" });
await store.put({ id: 3, text: "Call Sam", tag: "todo" });
const todos = await store.index("tag", "todo");
console.log("todo count:", todos.length);
// ✅ Expected output: todo count: 2
console.log("texts:", todos.map((r) => r.text));
// ✅ Expected output: texts: [ 'Buy milk', 'Call Sam' ]
}
main();🎯 Your Turn #2 — Choose the Right Mode
A write needs a read-write transaction. Replace the blank with the correct mode string.
function transaction(mode) {
if (mode === "readwrite") return "can read and write";
return "read only — write blocked";
}
// 👉 replace ___ with the mode that allows writing (a string)
const result = transaction("___");
console.log(result); // ✅ Expected output: can read and write🧠 Quick Recall
Predict the answer before revealing it.
Only inside the onupgradeneeded event handler.
No — it only stores strings, so you must JSON.stringify. IndexedDB stores structured objects directly.
From request.result inside the onsuccess handler.
Common Errors & Fixes
- 1️⃣ Creating a store outside onupgradeneeded
- 2️⃣ Writing in a readonly transaction
- 3️⃣ Awaiting unrelated work mid-transaction
- 4️⃣ Forgetting it is asynchronous
🎯 Mini Challenge — A Tiny Cache With Expiry
Build a promise-based cache: set(key, value, ttlMs) stores a record with an expiry time, and get(key) returns the value only if it has not expired (otherwise undefined). This is the heart of an offline data cache.
function makeCache(now = () => Date.now()) {
const data = new Map();
return {
set: (key, value, ttlMs) =>
new Promise((res) => {
data.set(key, { value, expires: now() + ttlMs });
res(key);
}),
get: (key) =>
new Promise((res) => {
const record = data.get(key);
if (!record || record.expires <= now()) return res(undefined);
res(record.value);
}),
};
}
async function main() {
// Controllable clock so the demo is deterministic
let clock = 1000;
const cache = makeCache(() => clock);
await cache.set("user:1", { name: "Ada" }, 50); // expires at 1050
clock = 1020; // still fresh
console.log("fresh:", await cache.get("user:1"));
// ✅ Expected output: fresh: { name: 'Ada' }
clock = 1100; // now expired
console.log("expired:", await cache.get("user:1"));
// ✅ Expected output: expired: undefined
}
main();Quick Reference — IndexedDB
| Goal | Code |
|---|---|
| Open | indexedDB.open("db", 1) |
| Create store | db.createObjectStore("s", { keyPath: "id" }) |
| Transaction | db.transaction("s", "readwrite") |
| Write | store.put(record) |
| Read | store.get(key) / store.getAll() |
| Index query | store.index("byTag").getAll(v) |
📚 Keep Learning
- • MDN — Using IndexedDB
- • MDN — IDBObjectStore
- • MDN — IDBTransaction
🎉 Lesson Complete — IndexedDB!
- ✅ A real client-side database for large, structured data
- ✅ Data lives in object stores; all access goes through transactions
- ✅ The request/event model is usually wrapped in promises for async/await
- ✅ Indexes let you query by properties other than the key
Practice quiz
How is IndexedDB different from localStorage?
- It only stores strings
- It blocks the main thread
- It is a transactional, asynchronous database that stores structured objects and large data
- It holds only a few kilobytes
Answer: It is a transactional, asynchronous database that stores structured objects and large data. IndexedDB is a real transactional, asynchronous database that stores structured objects (and files) and far more data than localStorage.
What is an object store in IndexedDB?
- IndexedDB's equivalent of a table that holds records keyed by a key
- A CSS file
- A network socket
- A type of index only
Answer: IndexedDB's equivalent of a table that holds records keyed by a key. An object store is like a table — it holds records keyed by a key.
Where are you allowed to create object stores and indexes?
- Anywhere after opening the database
- Inside onsuccess
- Inside a readonly transaction
- Only inside the onupgradeneeded event handler
Answer: Only inside the onupgradeneeded event handler. Object stores and indexes can only be created in the onupgradeneeded event, which fires on first open or version bump.
How do reads and writes happen in IndexedDB?
- Directly on the store with no setup
- Inside a transaction opened with db.transaction(store, mode)
- Through localStorage
- Only synchronously
Answer: Inside a transaction opened with db.transaction(store, mode). All reads and writes happen inside a transaction opened with db.transaction(storeName, mode).
Which transaction mode is required to write data?
- 'readwrite'
- 'readonly'
- 'write-only'
- 'upgrade'
Answer: 'readwrite'. Writing requires the 'readwrite' mode; 'readonly' blocks writes.
Why does IndexedDB use IDBRequest with onsuccess/onerror instead of returning values?
- It is synchronous
- To save memory
- It predates promises, so each operation fires events later and you read request.result
- Because it uses callbacks for styling
Answer: It predates promises, so each operation fires events later and you read request.result. It is an older event-based API; each operation returns an IDBRequest that fires onsuccess/onerror, with the value on request.result.
What is the common pattern for using IndexedDB with async/await?
- Avoid transactions
- Wrap each IDBRequest in a Promise that resolves on success and rejects on error
- Use localStorage instead
- Poll request.result in a loop
Answer: Wrap each IDBRequest in a Promise that resolves on success and rejects on error. Because the request/event model is awkward, you wrap each request in a Promise to use async/await.
What is an index used for in IndexedDB?
- To store images
- To speed up the network
- To encrypt the database
- To query records by a property other than the primary key
Answer: To query records by a property other than the primary key. By default you query by primary key; an index lets you query by another property and can enforce uniqueness.
Why must you avoid awaiting unrelated work in the middle of a transaction?
- It uses too much memory
- The transaction auto-commits when it has no pending requests and may close out from under you
- It encrypts the data
- It deletes the object store
Answer: The transaction auto-commits when it has no pending requests and may close out from under you. Transactions are short-lived and auto-commit when idle, so awaiting unrelated work can close the transaction.
Can localStorage store a JavaScript object directly?
- Yes, any object
- Only arrays
- No — it only stores strings, so you must JSON.stringify
- Only numbers
Answer: No — it only stores strings, so you must JSON.stringify. localStorage only stores strings (requiring JSON.stringify), while IndexedDB stores structured objects directly.
Continue this course
- Previous: MutationObserver (Watching the DOM)
- Next: The Canvas API — Draw shapes, paths, text and animations on a 2D canvas
- Quick reference: JavaScript cheat sheet
Frequently asked questions
How is IndexedDB different from localStorage?
localStorage stores only strings, holds a few megabytes, and blocks the main thread on every read/write. IndexedDB is a real transactional database: it stores structured objects (and files/blobs) directly, can hold hundreds of megabytes or more, supports indexes for fast lookups, and works asynchronously so it never freezes the UI. Use localStorage for tiny key-value flags; use IndexedDB for real datasets.
What is an object store and how does it relate to a transaction?
An object store is IndexedDB's equivalent of a table — it holds records keyed by a key. You never touch a store directly; you open a transaction over one or more stores, get the store from that transaction, and do your reads/writes inside it. The transaction guarantees all its operations succeed together or roll back together, and it auto-commits when there is no more work to do.
Why does IndexedDB use events and requests instead of returning values?
It is an older API designed before promises existed, so each operation returns an IDBRequest object that fires onsuccess or onerror later. You read the result from request.result inside onsuccess. Because that is awkward, almost everyone wraps each request in a Promise (resolve on success, reject on error) so they can use async/await — which is exactly the pattern this lesson teaches.
What are indexes for in IndexedDB?
By default you can only look records up by their primary key. An index lets you query by another property — for example create an index on 'email' so you can find a user by email instead of by id. You create indexes during the database upgrade (in onupgradeneeded), and they can enforce uniqueness too. They make non-key lookups fast instead of scanning every record.