IndexedDB
The browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
The problem: real data does not fit in localStorage
Picture a mail app used on a train. The user opens it, reads the 200 newest messages, starts a long reply, and the train enters a tunnel. Two things should still work: the inbox should still show, and the half-written reply must not vanish if the tab crashes or the phone kills it to save memory.
The first idea most teams try is localStorage. It is easy: localStorage.setItem("inbox", JSON.stringify(messages)). It works in the demo with 20 messages. Then real users arrive with 5,000 messages and attachments, and three problems show up at once.
Three walls you hit with localStorage
- It blocks the main thread. Every
getItemandsetItemis synchronous. Stringifying and writing 4 MB of JSON can take tens of milliseconds on a mid-range phone, and the page cannot respond to taps while it runs. Do it on every keystroke of a draft and typing feels sticky. - It only stores strings, and not many of them. Browsers give each origin about 5 MB of
localStorage, counted in characters with the keys included. A single photo attachment does not fit. Dates,Blobs, andMaps must be turned into strings and back by hand. - It has no queries. To find "unread messages from Priya, newest first", you read the whole blob, parse all of it, and filter in JavaScript. To change one message, you write the whole blob back. Two tabs doing that at once can overwrite each other's changes.
IndexedDB is the browser's answer to all three. It is a real database that ships in every modern browser: it stores structured JavaScript values (objects, arrays, dates, Blobs, typed arrays), it is asynchronous so it never freezes the page, it can hold hundreds of megabytes or more, and it has indexes and transactions so you can query a slice and change one record safely.
What IndexedDB is, and what it is not
IndexedDB is a key-value store with indexes, not a SQL database. You do not write queries in a language. You open a store, pick a key or an index, and ask for a value, a range of values, or a cursor that walks them in order. Think of it as a set of sorted maps that you can change together, all or nothing.
It lives per origin (scheme, host, and port), so https://mail.example.com cannot read the databases of https://example.com. It is available in pages, in web workers, and in service workers, which is why it is the usual place to keep data that a service worker needs while offline.
This lesson covers the database itself: its data model, transactions, versions, large values, wrappers, and how to use it for drafts and offline data. How much space the browser gives you, when it evicts data, and the Safari 7-day rule are covered in the client storage lesson. Caching whole HTTP responses for offline use belongs to the service worker caching lesson.
Check your understanding · Exercise 1
A notes app saves each note to localStorage as one JSON string of all notes, on every keystroke. Which problems will it run into as users write more? Select all that apply.
The mental model: sorted maps inside a versioned database
Keep one picture in your head: a filing cabinet, which is the database. Each drawer is an object store. Each folder in a drawer is a record, filed in order by the label on its tab, which is its key. An index is a card catalogue at the front of the drawer that lists the folders by some other field, like the sender's name, and tells you which tab to pull.
The cabinet also has a version number stamped on it. You can add, remove, or relabel drawers and catalogues only while you are changing that version. Day to day, you only take folders out and put them back. That split, structure changes during upgrades and data changes during normal use, explains most of the API.
Databases and object stores
An origin can have many databases, each with a name and an integer version. Most apps need one, named after the app (mail). Inside it, an object store plays the role of a table: messages, drafts, outbox.
Unlike a table, a store has no columns. Each record is any value the browser can copy with the structured clone algorithm, the same copying that postMessage uses.
Structured clone handles plain objects, arrays, strings, numbers, Date, Map, Set, RegExp, ArrayBuffer, typed arrays, Blob, and File. It cannot copy functions or DOM nodes, and it does not keep class prototypes: store a Message class instance and you get a plain object back. Store data, not behaviour, and rebuild classes when you read.
Keys and key paths
Every record has a key, and the store keeps records sorted by it. A valid key is a number (not NaN), a valid Date, a string, binary data (ArrayBuffer or a typed array), or an array of valid keys. Booleans, null, undefined, and objects are not keys. The order between types is fixed: numbers sort before dates, dates before strings, strings before binary, and binary before arrays.
You choose how a store finds the key when you create it. There are three common setups:
- In-line key with a key path.
createObjectStore("messages", { keyPath: "id" })reads the key from the record'sidfield. This is the usual choice when the server already gives each item an id. A key path can be dotted ("meta.id") or an array of paths for a compound key (["threadId", "seq"]). - Auto-increment.
createObjectStore("outbox", { autoIncrement: true })hands out 1, 2, 3, and so on, which makes the store a first-in, first-out queue. AddkeyPath: "id"too and the generated number is written into the record. - Out-of-line key. With neither option, you pass the key on each write:
store.put(value, key). This suits key-value use, like a store namedsettingskeyed by"theme".
A common mistake is a key path that points at a field some records lack. The put then fails with a DataError, and with auto-increment off there is no fallback. Validate records before writing, or generate ids on the client with crypto.randomUUID().
Indexes
An index is a second sorted list, kept up to date by the browser, that maps a field of each record to the record's primary key. Create byThread on threadId and the browser keeps entries like (t7, m1), (t7, m2) in order. Asking for thread t7 reads one short run of that list instead of scanning every message.
t7 sits together. One range read finds the run and jumps to the three records; the other two are never touched.- Compound index.
createIndex("byThreadDate", ["threadId", "sentAt"])sorts by thread, then by date inside each thread, so "the newest 20 messages of threadt7" is a single range read. - Unique index.
{ unique: true }makes the browser reject a write that would put two records under the same index key, such as two contacts with the same email. The write fails with aConstraintError. - Multi-entry index.
{ multiEntry: true }on an array field likelabelsadds one index entry per array item, so a message labelledworkandurgentis found under both.
Indexes are not free. Every write to the store also writes every index, and the index entries take space. Add an index for each query your screens actually run, not for every field. Records whose indexed field is missing, or is not a valid key, simply do not appear in that index, which is a handy way to build a sparse index such as "only unsent drafts".
Check your understanding · Exercise 2
You need to show the 50 newest messages in a folder, newest first. Which index makes that one efficient read? Pick one.
Reading and writing: requests, key ranges, and cursors
The raw API predates promises. Every operation returns an IDBRequest object right away, and the answer arrives later as a success or error event on it. Nothing blocks: the browser does the disk work on another thread and queues the event for your page when it is done.
const open = indexedDB.open("mail", 1);
open.onupgradeneeded = () => {
const db = open.result;
const messages = db.createObjectStore("messages", { keyPath: "id" });
messages.createIndex("byThread", "threadId");
};
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction("messages", "readwrite");
tx.objectStore("messages").put({ id: "m1", threadId: "t7", subject: "Lunch?" });
tx.oncomplete = () => console.log("saved to disk");
tx.onerror = () => console.error(tx.error);
};
Note where success is reported. The request's success means the browser accepted the operation. The transaction's complete means everything in it was committed. If you tell the user "saved", wait for complete.
The operations you will use every day
| Call | What it does | Watch out for |
|---|---|---|
get(key) | Returns one record, or undefined | A wrong key type also gives undefined: "42" and 42 are different keys |
getAll(range, count) | Returns many records as one array | Without a count, a range over 50,000 records copies all of them into memory |
getAllKeys(range, count) | Returns only the keys | Much cheaper than getAll when you only need ids |
add(value) | Inserts; fails if the key exists | A ConstraintError aborts the whole transaction unless you call preventDefault() on the error |
put(value) | Inserts or replaces | Replaces the whole record; there is no partial update |
delete(keyOrRange) | Removes one record or a range | Deleting a range is one call, not a loop |
count(range) | Counts without reading values | Cheap way to show "12 unread" |
The same read calls exist on an index. store.index("byThread").getAll("t7") returns every message in thread t7, sorted by the index key and then by primary key.
Key ranges: asking for a slice
Because keys are sorted, the natural query is a range. An IDBKeyRange describes one, and you pass it wherever a key is accepted. Each bound can be open (exclusive) or closed (inclusive):
IDBKeyRange.only("t7"): exactly one key.IDBKeyRange.lowerBound(1700000000000): everything from that timestamp on. Passtrueas a second argument to exclude the bound itself.IDBKeyRange.upperBound("m", true): everything strictly before"m".IDBKeyRange.bound(a, b, lowerOpen, upperOpen): everything between two keys.
Two tricks come up all the time. A prefix search on strings uses bound("pri", "pri\uffff"), since every string that starts with pri sorts between those two. A slice of a compound index fixes the first part and opens the second: bound(["t7", 0], ["t7", Infinity]) is every message in thread t7, sorted by time. Try ranges on a small set of keys below.
store.getAll(IDBKeyRange.bound("pri", "pri\uffff", false, false))
Returned, in order: pri, priti, priya.
Cursors: walking records one at a time
A cursor visits the records of a range in order, one per success event, so you never hold them all in memory. openCursor(range, "prev") walks backwards, which is how you read "newest first". On each step you can read cursor.value, change it with cursor.update(), remove it with cursor.delete(), then call cursor.continue() to move on or cursor.advance(n) to skip ahead.
// Mark every message in thread t7 as read, without loading all of them at once.
const tx = db.transaction("messages", "readwrite");
const range = IDBKeyRange.only("t7");
const req = tx.objectStore("messages").index("byThread").openCursor(range);
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return; // walked past the end of the range
if (cursor.value.unread === 1) cursor.update({ ...cursor.value, unread: 0 });
cursor.continue();
};
Use a cursor when you need to stop early, change records as you go, or process more records than you want in memory. Use getAll with a count when you want a page of results in one go: it costs one round trip between your code and the database instead of one per record, and is usually much faster for a few hundred records.
There is no offset. To show page 3, either advance(40) on a cursor (which still walks the first 40 entries) or, better, remember the last key of page 2 and start page 3 with lowerBound(lastKey, true). That is keyset pagination, the same idea the pagination lesson uses for APIs.
Check your understanding · Exercise 3
A contacts store has a unique index on lowercase names. Which range returns every contact whose name starts with "sam"? Pick one.
Transactions: all or nothing, and short-lived
Every read and write in IndexedDB happens inside a transaction. A transaction groups operations so they succeed or fail together. Move a message from outbox to messages in one transaction, and a crash halfway leaves neither a lost message nor a duplicate: the browser either commits both changes or rolls both back.
You create one with db.transaction(storeNames, mode). The store names are its scope: it can touch only those stores. The mode is one of three:
readonly: reads only. Many readonly transactions can run at the same time, even on the same stores. This is the default, so forgetting"readwrite"makes everyputthrow aReadOnlyError.readwrite: reads and writes. Two readwrite transactions whose scopes overlap run one after the other, in the order they were created. Ones with separate scopes can run in parallel.versionchange: created by the browser during an upgrade, never by you. It is the only mode that can create or delete stores and indexes, and it locks the whole database.
The scheduling rule has a practical side. A long readwrite transaction on messages makes every other write to messages, from any tab of the same origin, wait in line. Keep scopes narrow (only the stores you need) and transactions short.
Auto-commit: the rule that surprises everyone
There is no begin and, usually, no commit call. A transaction is active while your code runs synchronously after creating it, and again inside each of its request callbacks. When the browser has finished every request you placed, and your code returns to the event loop without placing a new one, the transaction commits by itself.
This is why the following code fails. While fetch is in flight, the transaction has nothing pending, so it commits. When the response arrives 300 ms later, the code tries to put into a transaction that is already finished, and the browser throws a TransactionInactiveError.
// Broken: the transaction commits while fetch is waiting.
const tx = db.transaction("messages", "readwrite");
const store = tx.objectStore("messages");
const res = await fetch("/api/messages?since=" + lastSync);
const fresh = await res.json();
for (const m of fresh) store.put(m); // TransactionInactiveError
// Fixed: do the slow work first, then one short transaction.
const res2 = await fetch("/api/messages?since=" + lastSync);
const fresh2 = await res2.json();
const tx2 = db.transaction("messages", "readwrite");
for (const m of fresh2) tx2.objectStore("messages").put(m);
await new Promise((ok, fail) => { tx2.oncomplete = ok; tx2.onerror = () => fail(tx2.error); });
fetch leaves it idle, so it commits. Do the network work first, then open one short transaction for the writes.Awaiting the database's own promises (with a wrapper like idb) is fine, because promise callbacks run as microtasks before control returns to the event loop, and the next request is placed in time. What breaks a transaction is waiting on anything that needs a new task: fetch, setTimeout, a postMessage reply, or a user click. The rule of thumb: gather everything first, then open the transaction and write.
Commit, abort, and errors
tx.commit()ends a transaction at once instead of waiting for auto-commit. It is a small speed-up when you know you are done, and it is supported in all current browsers.tx.abort()rolls back everything the transaction did. Use it when a check fails halfway, such as "the draft was already sent in another tab".- A failed request aborts the transaction by default. A duplicate key in
addrolls back every other write in the same transaction. To survive an expected error, callevent.preventDefault()in the request'serrorhandler. - Listen to
complete,error, andaborton the transaction, not only on requests. A transaction can also abort for reasons outside your code, such as aQuotaExceededErrorwhen the disk is full.
Transactions are isolated from each other only in the simple sense above: overlapping readwrite transactions do not interleave. There are no row locks you can take across an await, and no way to hold a transaction open while a user decides something. Design writes as short, self-contained steps.
Check your understanding · Exercise 4
Which of these, placed between two puts in the same readwrite transaction, will make the second put fail with TransactionInactiveError? Select all that apply.
Versions and migrations
Your schema will change: version 1 of the mail app has a messages store, version 2 adds a drafts store, and version 3 adds a byThreadDate index and renames a field. Users jump between these versions in any order: someone who last opened the app eight months ago goes straight from 1 to 3.
IndexedDB handles this with an integer version per database. indexedDB.open("mail", 3) asks for version 3. If the stored database is older, or does not exist (version 0), the browser fires upgradeneeded and runs your handler inside a versionchange transaction. That handler is the only place where stores and indexes can be created or deleted.
What happens when you call open
- Same version as stored:
successfires and you get the database. No upgrade. - Higher version: the browser first asks every other open connection to this database to close (the
versionchangeevent). If any stay open, your request gets ablockedevent and waits. Once all are closed,upgradeneededfires, your migration runs, and thensuccess. - Lower version than stored: the open fails with a
VersionError. Versions only go up. This is what happens when an old cached build, in a forgotten tab or served by a stale service worker, opens the database after a newer build upgraded it. - No version given: you get whatever version is stored, or version 1 for a new database.
blocked for as long as an old tab keeps its connection. Asking for a lower version than stored skips all of this and fails at once with a VersionError.The migration pattern: one step per version
Write the upgrade as a list of steps, one for each version, and run every step newer than the user's old version. The handler gets oldVersion, so a user coming from 1 runs steps 2 and 3, and a brand-new user runs all of them. Never edit an old step after release; add a new one.
import { openDB } from "idb";
const db = await openDB("mail", 3, {
upgrade(db, oldVersion, newVersion, tx) {
if (oldVersion < 1) {
const messages = db.createObjectStore("messages", { keyPath: "id" });
messages.createIndex("byThread", "threadId");
}
if (oldVersion < 2) {
db.createObjectStore("drafts", { keyPath: "id" });
}
if (oldVersion < 3) {
const messages = tx.objectStore("messages");
messages.deleteIndex("byThread");
messages.createIndex("byThreadDate", ["threadId", "sentAt"]);
}
},
});
Changing the data itself (renaming date to sentAt in every record, say) also happens through the upgrade transaction: open a cursor on tx.objectStore("messages") and update each record. Keep it to work the database can do alone. The same auto-commit rule applies, so you cannot fetch in the middle of an upgrade.
For a large data migration, think about the user's wait. Rewriting 100,000 records can take seconds, and the app cannot use the database until it finishes. Two common ways out: if the data is a cache of the server, delete the store and refill it from the network; or keep both shapes for a while and convert records lazily when they are read.
The two-tab problem: blocked and versionchange
The hardest part of upgrades is not the migration code. It is the tab the user left open yesterday, still running the old JavaScript with a connection at version 2. Your new tab asks for version 3. The browser cannot upgrade while that old connection exists, so the new tab waits, and by default it waits forever.
The fix is to plan for it in every version of your code, starting with the first release:
- In every tab, handle
versionchange(theblockingcallback inidb): close the connection withdb.close()and show a "A new version is available, please reload" banner. Save any unsaved draft first. - In the new tab, handle
blocked: show "Close other tabs of this app to finish updating" instead of an endless spinner. - Handle the
closeevent on the connection (theterminatedcallback inidb). The browser fires it if the database is closed from outside, for example when the user clears site data. Reopen or tell the user.
Step through it below. Then try the bad path: the old tab has no handler, and the new tab never gets its database.
- Start a long write in tab A, then deploy. Try it again with the handlers switched off.
Check your understanding · Exercise 5
A user has the app open at database version 4 in one tab. A service worker serves a stale build in a new tab that calls open("mail", 3). What happens? Pick one.
Large data and Blobs
IndexedDB is where the browser expects big data to go. Origins can usually store hundreds of megabytes, often gigabytes, limited by free disk space. The exact limits, how to ask for persistent storage, and when browsers evict data are in the client storage lesson. Here the question is how to store large things well.
Store Blobs as Blobs
A Blob or File can be a record, or a field of one: { id: "a1", messageId: "m1", name: "plan.pdf", body: file }. The browser writes the bytes to its own storage, usually as a separate file on disk, and gives you back a Blob when you read the record. In current browsers, reading the record usually does not load the bytes into memory; they are read only when you call blob.arrayBuffer(), blob.text(), or hand it to URL.createObjectURL().
So do not convert files to base64 strings or ArrayBuffers before storing them. Base64 makes the data about 33 percent larger, forces the whole thing through JavaScript memory, and turns a cheap read into an expensive one. Older guides recommend converting because very old Safari versions had bugs storing Blobs; current browsers store them correctly.
// Save an attachment the user picked, then show it later.
const file = input.files[0];
await db.put("attachments", { id: crypto.randomUUID(), messageId, name: file.name, body: file });
const att = await db.get("attachments", id);
const url = URL.createObjectURL(att.body); // no bytes copied into JS
img.src = url;
img.onload = () => URL.revokeObjectURL(url); // free the URL when done
Many small records beat one huge record
Every put serializes its value with structured clone synchronously, on the thread that calls it, before the async part starts. That is why changing an object right after put does not change what is stored. It is also why put of a 20 MB object graph can freeze the page for a noticeable time, even though the API is "async". Reads have the same cost in reverse: the value is rebuilt on your thread when the result arrives.
That leads to three rules for large data:
- Split by the unit you change. Store each message as its own record, not the inbox as one array. Changing one message then rewrites a few kilobytes, not megabytes.
- Keep metadata and bytes apart. Put attachment bytes in an
attachmentsstore and only their ids in the message. The inbox list reads small records and never touches the PDFs. - Move heavy work off the main thread. IndexedDB works in web workers. A worker that syncs and writes 10,000 records keeps the page responsive, and the page reads only what it shows.
When the disk says no
Large writes can fail. If the origin is over its quota, the request fails and the transaction aborts with a QuotaExceededError. Treat it as a normal outcome, not a crash: evict old cached data (the oldest threads, attachments you can download again), retry once, and tell the user if a draft could not be saved. navigator.storage.estimate() reports usage and quota so you can clean up before you hit the wall.
For very large files that you read and write in pieces, like a video editor's working file or a SQLite database compiled to WebAssembly, the Origin Private File System (OPFS) is often a better fit than IndexedDB. It gives you file handles with fast synchronous access inside workers. IndexedDB stays the better choice for many records that you query by key and index.
Check your understanding · Exercise 6
An app stores each 2 MB photo as a base64 string inside the message record, and the inbox list feels slow. What are the best fixes? Select all that apply.
Wrappers: idb, Dexie, or the raw API
The raw API works, but its event callbacks make simple code long and error handling easy to forget. Almost every team uses a wrapper. The two you should know are idb, a tiny promise layer by Jake Archibald, and Dexie, a fuller database library with its own schema syntax and live queries.
idb: the same API, with promises
idb keeps IndexedDB's concepts exactly and replaces requests with promises. openDB takes the version and an object of callbacks (upgrade, blocked, blocking, terminated). Requests become promises, transactions get a done promise, cursors get await cursor.continue(), and there are shortcuts like db.get("messages", id) that open a one-request transaction for you. It is about 1.2 KB with brotli compression, and it ships TypeScript types where you describe your schema once.
// Save a page of messages from the server in one transaction.
async function saveMessages(db, messages) {
const tx = db.transaction(["messages", "meta"], "readwrite");
await Promise.all([
...messages.map((m) => tx.objectStore("messages").put(m)),
tx.objectStore("meta").put(Date.now(), "lastSync"),
tx.done, // resolves on complete, rejects on error or abort
]);
}
// Newest 20 messages of a thread, newest first, with a cursor.
async function latest(db, threadId) {
const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]);
const out = [];
let cursor = await db.transaction("messages").store.index("byThreadDate").openCursor(range, "prev");
while (cursor && out.length < 20) {
out.push(cursor.value);
cursor = await cursor.continue();
}
return out;
}
Note Promise.all with tx.done in the list. If one put fails, the transaction aborts and tx.done rejects, so the caller sees the error. Without tx.done you might report success for writes that were rolled back. And because every await above is on a request of the same transaction, the transaction never goes inactive.
Dexie: a database library on top
Dexie describes the schema as strings, db.version(3).stores({ messages: "id, threadId, [threadId+sentAt], *labels" }), and works out the stores and indexes for each version (* marks a multi-entry index, [a+b] a compound one). It adds a query builder (where("threadId").equals("t7").reverse().limit(20)), bulk methods, and liveQuery, which re-runs a query when the data it read changes, even when the change came from another tab. It is larger than idb, measured in tens of kilobytes.
Decision Which way should a team talk to IndexedDB?
What we pick: For most apps that store a few kinds of records and query them simply, idb is the right default: small, honest, and close to the platform. Pick Dexie when the app is data-heavy on the client (many stores, many queries, UI that must react to local changes across tabs), because its live queries replace a lot of hand-written plumbing. Use the raw API only inside a library, or when every kilobyte counts and the use is tiny.
Libraries that sit higher up also use IndexedDB underneath. TanStack Query's persisters and Workbox's expiration plugin can use it, and local-first tools like Yjs (through y-indexeddb) and RxDB store their data in it. When you choose one of those, you still inherit IndexedDB's rules about quotas, versions, and other tabs.
Check your understanding · Exercise 7
With idb, a function writes 300 records with tx.store.put() in a loop but never awaits anything, and returns. What can go wrong? Select all that apply.
Performance: batching, bulk reads, and durability
IndexedDB has a reputation for being slow. Most of that comes from using it one record at a time. The database does its disk work off your thread, but each transaction has a fixed cost (scheduling, locking, and a commit that may wait for the disk), and each request has a smaller cost (a clone and an event back to your code). Fast code pays those fixed costs as few times as possible.
Batch writes into one transaction
Saving 1,000 synced messages with await db.put(...) in a loop opens 1,000 transactions, each with its own commit. Saving them with 1,000 put calls in one transaction pays for one commit. The second is often an order of magnitude faster, and it is also correct in a way the first is not: the sync either lands fully or not at all.
The other half of batching is not awaiting each request. Place all the requests, then await them together (or await tx.done). Awaiting each put in turn inside one transaction is safe, but it adds a trip through the event machinery for every record. Play with the counts below.
Batches can be too big, too. One transaction that writes 200,000 records holds the store's write lock for its whole run, so every other write waits, and an error at record 199,999 throws away all the work. For big imports, use chunks of a few hundred to a few thousand records per transaction, and keep a checkpoint ("imported up to key X") so a reload can resume.
Bulk reads: getAll, keys, and counts
getAll(range, count)over a cursor when you want a page: one result event instead of one per record.getAllKeysandcountwhen you need ids or numbers, not values: no values are cloned.- Index the query, do not filter in JavaScript. A compound index that matches the screen's sort order turns "read 20,000, keep 50" into "read 50".
- Read only what the screen shows. A virtualized list of 10,000 messages (one that renders only the visible rows) needs the 30 visible rows and a count, not 10,000 records in a React state array.
Durability hints
When a readwrite transaction commits, the browser can ask the operating system to flush the data to disk before it fires complete (strict durability). Or it can fire complete once the data is handed to the OS, which writes it out a moment later (relaxed). Relaxed is much faster, and the risk is small: a power cut or OS crash in the next few seconds can lose the latest writes. A crash of the browser alone does not.
You can choose per transaction: db.transaction("drafts", "readwrite", { durability: "strict" }). Chrome has accepted the option since version 83, Safari since 15, and Firefox since 126. Relaxed is the default in Firefox and Safari, and in Chrome since version 121, so most writes are relaxed unless you ask for more.
So the real decision is when to ask for strict. Use it for data that exists only on this device, like an unsent draft or a queued write, and for migrations where you delete the old copy only after the new one is written. Cached API responses that you can fetch again can stay relaxed. Even strict is not a full guarantee against power loss, but it makes the window much smaller.
Other costs worth knowing
- Opening the database costs a few milliseconds up to much more on a cold start. Open once per page or worker and reuse the connection; do not open per query.
- Each index adds write cost. Ten indexes on a store make every
putupdate eleven sorted structures. - Cloning is on your thread. Big values, many fields, and deeply nested objects cost CPU to store and to read back. Keep records flat and move heavy sync to a worker.
- Keep an in-memory copy for hot data. IndexedDB is a durable store, not a render cache. Read once into memory (or into your query cache), render from there, and write changes back.
Check your understanding · Exercise 8
A sync job writes 5,000 messages with for (const m of list) await db.put("messages", m) and takes several seconds. What is the main fix? Pick one.
Using it for drafts and offline data
Two jobs bring most frontend teams to IndexedDB: never losing what the user typed, and showing useful data without a network. Both are about keeping data on the device that matters more than a cache. This chapter builds both for the mail app. Syncing queued changes back to the server is covered in the offline module.
Drafts that survive a crash
A draft store is simple: drafts, keyed by a draft id the client generates, each record holding the text, the recipients, the thread it replies to, and updatedAt. Attachments go in the attachments store with a draftId index. The interesting part is when to write.
- Debounce while typing. Save about 500 ms to 1 second after the user stops typing, not on every key. One small
putper pause is cheap; the debounce lesson covers the timing. - Flush when the page hides. On
visibilitychangetohidden, start the pending save at once. Mobile browsers may kill a background tab without warning, andvisibilitychangeis the last event you can rely on. Start the write synchronously in the handler. - Restore on load. When the compose screen opens, read the draft by id and show it, with a quiet "Restored draft from 10:42" note.
- Delete after the send succeeds, in the same step that moves the message to the outbox, so the draft never shows up twice.
Saved only after complete, and read the draft back on load.Two tabs editing the same draft is the edge case to plan for. Keep updatedAt (or a version counter) in the record, and before writing, read the stored one inside the same readwrite transaction: if it is newer than the copy this tab loaded, another tab changed it. Tell the user, or merge. Post a message on a BroadcastChannel (an API for messages between tabs of one origin) after each save so other tabs can refresh; the cross-tab sync lesson covers the patterns.
Offline data: a local copy of the server
For reading offline, the app keeps a local copy of the data the user is likely to need: the newest 500 messages of each folder, the contact list, labels. The layout that works for most apps has four kinds of store:
- Entity stores (
messages,contacts) keyed by server id, with indexes that match the screens. Each record can carrysyncedAt. - A
metastore for sync state: the server cursor orlastSynctimestamp per collection, and the schema version of the cached data. - An
outboxstore with auto-increment keys: writes made offline, in order, waiting to be sent. Auto-increment gives first-in, first-out for free. - An
attachmentsstore for Blobs, kept apart so it can be cleaned up by size.
The page renders from IndexedDB first, so the inbox shows in milliseconds even on a bad connection, then asks the server for changes since lastSync and writes them in one transaction. A service worker can read the same database, because IndexedDB works in every worker. HTTP responses for the app shell belong in the Cache API, as the service worker caching lesson explains; structured records belong here.
What breaks in production
| Problem | How you notice | Fix |
|---|---|---|
| Old tab blocks an upgrade | Users stuck on a spinner after a deploy | versionchange handler that closes and asks for a reload; blocked handler with a message |
| Quota exceeded | QuotaExceededError on writes, failed syncs | Evict re-downloadable data first; check navigator.storage.estimate() |
| Data evicted by the browser | Users report empty drafts after weeks away | Request persistent storage; treat local data as possibly missing (see client storage) |
| Transaction inactive | TransactionInactiveError in logs | No non-database await inside a transaction |
| Silent rollbacks | "Saved" shown but data missing | Await tx.done or complete; listen to abort |
| Private windows | Data gone after the window closes | Expected: private modes keep IndexedDB only for the session |
| Schema drift with the server | Crashes reading old cached records | Version the cached data in meta; clear and refetch on mismatch |
Log the error name (QuotaExceededError, VersionError, InvalidStateError, UnknownError) from every failed open and transaction to your monitoring. UnknownError on open can mean a damaged database on disk; a tested "delete and rebuild from the server" path is worth having for cached data.
Security and privacy
IndexedDB data is readable by any script running on the origin. A cross-site scripting (XSS) bug that can read localStorage can read every message in IndexedDB too, so the XSS defences in the security lessons protect this data as well. Data is stored unencrypted from the app's point of view (the OS may encrypt the disk). On a shared computer, clear the user's databases on logout: indexedDB.deleteDatabase("mail"), after closing your own connections, or key databases by user (mail-u123) so accounts never mix.
Check your understanding · Exercise 9
Users on phones lose the last few seconds of a draft when they switch apps. Saves are debounced by 1 second. What is the best fix? Pick one.
In real products, and in the interview
Where you will find it
- Firebase's Firestore web SDK can keep a persistent local cache in IndexedDB, so queries answer from disk first and writes made offline are sent later.
- Workbox, Google's service worker library, keeps the timestamps for its cache expiration rules in IndexedDB, while the responses themselves live in the Cache API.
- Linear's engineers have described a sync engine that keeps a local copy of workspace data in IndexedDB, which helps its UI respond instantly after a reload without waiting on the network.
- Excalidraw (the excalidraw.com app) keeps the images pasted into a drawing in IndexedDB rather than in
localStorage, where they would not fit. - Local-first libraries such as Yjs (with
y-indexeddb), RxDB, and Dexie Cloud all persist to IndexedDB in the browser.
The common shape: memory for what is on screen, IndexedDB for the durable local copy, the server as the source of truth that the local copy syncs with. Seen from the browser, IndexedDB is the bottom layer every one of them relies on.
When to bring it up
Bring IndexedDB up whenever the requirements say offline, drafts, "must not lose work", large client-side data (thousands of records, files, images), or instant startup on repeat visits. Typical prompts: an email client, a notes or docs app, a chat app with history, a photo uploader that survives reloads, a field-work app used without signal.
Do not bring it up for small UI preferences or for data that is only useful fresh, like a stock ticker. Saying "localStorage is fine for the theme, IndexedDB for the 5,000 messages" shows judgement.
What to draw and say
- The stores and their keys. Write them like a schema:
messages(keyid, index[folderId, sentAt]),drafts(keyid),outbox(auto-increment),attachments(keyid, indexmessageId),meta. - The read path. Render from memory, fill memory from IndexedDB on start, refresh from the server, write the changes back in one transaction.
- The write path. Optimistic update in memory, durable write to IndexedDB (drafts, outbox), then the network. Mention debounce and the flush on
visibilitychange. - The edges. Schema versions and the old-tab problem, quota errors and eviction, logout clearing data, and other tabs seeing the change.
| Level | What a strong answer includes |
|---|---|
| Junior | Knows IndexedDB is async, stores objects and Blobs, holds far more than localStorage; picks it for offline data and drafts |
| Mid | Designs stores, keys, and indexes for the screens; explains transactions, batching, and the auto-commit trap; uses idb or Dexie |
| Senior | Plans versioned migrations and the two-tab upgrade, quota and eviction handling, durability per store, cross-tab updates, worker offloading, logout and privacy |
Check your understanding · Exercise 10
In a notes app design, which statements belong in a senior-level answer about local storage? Select all that apply.
Summary
- IndexedDB is the browser's async, transactional key-value database: object stores of structured-clone values, sorted by key, with indexes for other fields. Use it for anything that grows, holds binary data, needs a query, or is written often.
- Design keys and indexes for the screens: a compound index like [folderId, sentAt] turns "newest 50 in this folder" into one range read. Booleans are not keys.
- Transactions auto-commit as soon as they have no pending requests when control returns to the event loop. Never await fetch or a timer inside one: fetch first, then write in one short transaction.
- Schema changes happen only in upgradeneeded, one step per version, keyed off oldVersion. Every tab must close its connection on versionchange, or the next deploy hangs on blocked.
- Store files as Blobs, not base64. Keep records small and split by the unit you change; cloning happens on the calling thread, so move heavy sync to a worker.
- Batch writes into one transaction (chunked for big imports), read pages with getAll and a count, and keep the default relaxed durability for re-downloadable data and ask for strict on drafts and queued writes.
- Use idb by default (about 1.2 KB with brotli, same concepts with promises) and Dexie when the app needs a query builder and live queries across tabs.
- For drafts: debounce saves, flush on visibilitychange to hidden, restore on load, delete after send. Always await completion before telling the user it is saved.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Why would you choose IndexedDB over localStorage for an email client's offline inbox?
localStorage is synchronous, so reading and writing a large inbox blocks the main thread. It holds about 5 MB of strings per origin, and it has no queries: every change rewrites the whole blob, and two tabs can overwrite each other.
IndexedDB is asynchronous, stores structured values and Blobs, typically allows hundreds of megabytes or more, and has indexes and transactions. I can store each message as a record, query a folder's newest 50 with a compound index, and change one message atomically.
I would still keep tiny preferences like the theme in localStorage, because reading them synchronously at startup avoids a flash.
Question 2
A teammate's code throws TransactionInactiveError. What is the likely cause and fix?
A transaction commits automatically once it has no pending requests and control returns to the event loop. If the code awaits fetch, a timer, or a message from another context between creating the transaction and writing, the transaction has already committed by the time the write runs.
The fix is to do all network and other async work first, then open a readwrite transaction, place all the writes, and await its completion. Awaiting the transaction's own requests (with idb) is fine because their callbacks run as microtasks while it is still active.
Question 3
How do you ship a schema change to IndexedDB safely?
Bump the version and add a new step to the upgrade handler: each step runs when oldVersion is below its number, so users on any old version run exactly the steps they missed. Old steps are never edited. Structure changes and data rewrites happen inside the upgrade transaction, with no network calls.
Plan for open tabs: the upgrade cannot run while an older connection exists. Every version of the app handles versionchange by saving state, closing the connection, and asking the user to reload, and the new tab handles blocked with a clear message instead of a spinner.
For a cached copy of server data, deleting the store and refetching is often simpler and faster than migrating each record.
Question 4
How would you make draft saving reliable in a web editor?
A drafts store keyed by a client-generated id, with text, metadata, and updatedAt; attachments as Blobs in a separate store. Saves are debounced by about a second while typing, flushed immediately on visibilitychange to hidden, and use strict durability because the data exists only on this device.
The UI shows saved only after the transaction completes. On load the draft is restored. After a successful send, the draft is deleted in the same transaction that adds the message to the outbox. For two tabs, a version check inside the write transaction detects conflicts, and a BroadcastChannel message tells other tabs to refresh.
Question 5
Syncing 20,000 records into IndexedDB is slow and freezes the page. How do you fix it?
First, batch: write in chunks of a few hundred to a few thousand records per transaction, placing all puts before awaiting, instead of one transaction per record. That cuts commits from 20,000 to a handful and makes each chunk atomic.
Second, move the sync to a web worker, since structured clone runs on the calling thread. Third, keep the default relaxed durability (do not force strict) since the data can be fetched again. Finally, check the indexes: each one adds work to every write, so drop any no screen uses.
Practice it
These design problems lean on indexeddb.
- Design a recently viewed products featureEasy
- Design a file uploaderMedium
- Design a markdown editor with live previewMedium
- Design a multi-step form wizardMedium
- Design an emoji and GIF pickerMedium
- Design a client-side analytics SDKMedium
- Design state sync across browser tabsMedium
- Design a client-side rate-limited action queueMedium
- Design a QR code event check-in appMedium
- Design a gamified learning app like DuolingoMedium
- Design an online exam platformMedium
- Design a combobox for 100,000 optionsHard
- Design a rich text editorHard
- Design undo and redoHard
- Design a chat appHard
- Design DiscordHard
- Design a collaborative document editor like Google DocsHard
- Design a whiteboard like ExcalidrawHard
- Design GmailHard
- Design Google DriveHard
- Design SlackHard
- Design an online code playground like CodePenHard
- Design an online judge like LeetCodeHard
- Design a browser extensionHard
- Design a web photo editorHard
- Design a browser-based music studioHard
- Design an offline sync engineHard
- Design Google PhotosHard
- Design a browser video editorHard
References
- MDN: IndexedDB API Reference for every interface, event, and error.
- MDN: Using IndexedDB A full walkthrough of opening, upgrading, transactions, and cursors.
- W3C: Indexed Database API 3.0 The specification, including transaction lifetime and key ordering.
- idb on GitHub (Jake Archibald) The promise wrapper, with notes on transaction lifetime.
- Dexie.js documentation Schema syntax, queries, and liveQuery.
- web.dev: Work with IndexedDB A guide that uses the idb library.
- MDN: IDBDatabase.transaction() and the durability option Modes, scope, and durability hints.
Read next
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- Background sync and queued writesSaving the user's writes in a durable queue when the network is gone, then sending each one exactly once when it comes back, even if the tab was closed: the write half of every offline-capable app.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.