cf.completefrontend
My progress
FUNDAMENTALS · MODULE 9

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 getItem and setItem is 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, and Maps 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.

A comparison of localStorage and IndexedDB. API: synchronous against asynchronous. What it stores: strings only against objects and Blobs. Typical room: about 5 MB against hundreds of megabytes or more. Queries: none against keys and indexes. Safe multi-write: no against transactions. Available in workers: no against yes.localStorageIndexedDBAPIsynchronousasynchronousWhat it storesstrings onlyobjects, BlobsTypical roomabout 5 MBhundreds of MB+Queriesnonekeys, indexesSafe multi-writenotransactionsIn workersnoyes
localStorage is fine for a few small settings. Every row where it falls short is a reason real app data moves to IndexedDB.

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.

Inside the origin mail.example.com is a database named mail at version 3. It holds four object stores: messages keyed by id, drafts keyed by id, outbox with auto-increment keys, and attachments keyed by id. The messages store has two indexes, byThreadDate and byLabel; attachments has one index, byMessage. A note says each record is any structured-clone value, including Blobs, and that stores and indexes change only during a version upgrade.origin: https://mail.example.comdatabase: mail, version 3messageskey: iddraftskey: idoutboxauto-incrementattachmentskey: idbyThreadDatebyLabelbyMessagea record: any cloneable value:objects, dates, Blobs, Filesstores, indexes: upgrades only
Database, then object stores, then records sorted by key, with indexes as extra sorted lists beside a store. The structure (the dashed and solid boxes) changes only in an upgrade; the records change all the time.

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's id field. 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. Add keyPath: "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 named settings keyed 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.

The byThread index is a sorted list of entries: t3 for m3, t7 for m1, t7 for m2, t7 for m4, and t9 for m5. The query getAll for thread t7 reads the three neighbouring t7 entries and follows them to records m1, m2, and m4 in the messages store, which is sorted by id. Records m3 and m5 are never read.index("byThread").getAll("t7")t3 · m3t7 · m1t7 · m2t7 · m4t9 · m5m1m2m3m4m5byThread indexmessages store, sorted by id3 read, 2 skipped
The index keeps (thread, key) pairs in order, so all of thread 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 thread t7" 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 a ConstraintError.
  • Multi-entry index. { multiEntry: true } on an array field like labels adds one index entry per array item, so a message labelled work and urgent is 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.

js
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);
};
Open (and create) a database, then write one record. The write is only safe once the transaction's complete event fires.

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

CallWhat it doesWatch out for
get(key)Returns one record, or undefinedA wrong key type also gives undefined: "42" and 42 are different keys
getAll(range, count)Returns many records as one arrayWithout a count, a range over 50,000 records copies all of them into memory
getAllKeys(range, count)Returns only the keysMuch cheaper than getAll when you only need ids
add(value)Inserts; fails if the key existsA ConstraintError aborts the whole transaction unless you call preventDefault() on the error
put(value)Inserts or replacesReplaces the whole record; there is no partial update
delete(keyOrRange)Removes one record or a rangeDeleting a range is one call, not a loop
count(range)Counts without reading valuesCheap 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. Pass true as 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))

A store with nine string keys in sorted order: ana, pri, priti, priya, raj, sam, samir, sara, zoe. The range matches 3 keys: pri, priti, priya. Returned in order: pri, priti, priya.anapripritipriyarajsamsamirsarazoe1233 in range, 3 returned
Keys are always sorted, so a range is one contiguous run. Shaded keys are in the range; highlighted ones are what comes back after the direction and count are applied, numbered in the order you receive them.

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.

js
// 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 every put throw a ReadOnlyError.
  • 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.

A sequence diagram with Tab A, the mail database, and Tab B. Tab A starts transaction T1, readwrite on messages, and it runs. Tab B starts T2, also readwrite on messages; it waits because the scopes overlap. T1 commits. Only then does T2 run, and later it commits. A note says readwrite transactions with separate stores, and readonly transactions with each other, can run at the same time.Tab Amail databaseTab BT1 readwrite: messagesT1 runsT2 readwrite: messagesT2 waitsT1 completeT2 runsseparate stores, or readonly with readonly: run at once
Overlapping readwrite transactions are queued in the order they were created, across every tab of the origin. A slow transaction in one tab delays writes in all of them.

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.

js
// 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); });
Two timelines over about 320 milliseconds. Wrong: a readwrite transaction is created, then a fetch starts. With nothing pending, the transaction commits after a few milliseconds. When the fetch returns at 300 milliseconds, the put fails with TransactionInactiveError. Right: the fetch runs first with no transaction open, and at 300 milliseconds one short transaction does every put and commits.wrongawait fetch(): nothing pendingcommitted at about 6 msput: TransactionInactiveErrorrightfetch first, no transaction yetone transaction: put all, commit0 ms300 ms
A transaction lives only while it has database work pending. Waiting on 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 add rolls back every other write in the same transaction. To survive an expected error, call event.preventDefault() in the request's error handler.
  • Listen to complete, error, and abort on the transaction, not only on requests. A transaction can also abort for reasons outside your code, such as a QuotaExceededError when 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

  1. Same version as stored: success fires and you get the database. No upgrade.
  2. Higher version: the browser first asks every other open connection to this database to close (the versionchange event). If any stay open, your request gets a blocked event and waits. Once all are closed, upgradeneeded fires, your migration runs, and then success.
  3. 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.
  4. No version given: you get whatever version is stored, or version 1 for a new database.
A state machine for indexedDB.open with version 3. From the open call: if the stored version is the same, go straight to success. If the requested version is higher, other connections receive versionchange. If they all close, upgradeneeded runs the migration; if a tab stays open, the request is blocked until it closes, then upgradeneeded runs. If the migration commits, success; if it throws, the upgrade is aborted and the open fails with an error.open(mail, 3)compare versionssuccessdb readyversionchangeold tabs askedupgradeneededmigration runsblockedwaits for tabserrorupgrade abortedsame versionhigher versionall closedtab stays openit closescommitsthrows
Only the higher-version path does any work, and it can stall in 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.

js
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"]);
    }
  },
});
Each step runs once, in order, inside the single upgrade transaction. A failure in any step rolls back the whole upgrade and the version stays where it was.

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 (the blocking callback in idb): close the connection with db.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 close event on the connection (the terminated callback in idb). 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.

Two tabs and the mail database at version 3. Tab A: reading mail, connected at version 3. Tab B: not open yet.Tab A: old codereading mailconnected to v3mailversion 3
Tab A runs last week's code; tab B runs today's. The upgrade to version 4 can only run once tab A's connection, and any transaction still running on it, is gone.
  1. 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.

js
// 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 attachments store 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.
Before and after. Before: the whole inbox is one 4 MB record, and marking one message as read clones and writes all 4 MB. After: each of 2,000 messages is its own record of about 2 KB, and marking one as read clones and writes only that 2 KB record.one record: inbox4 MB arraymark 1 read: write 4 MB2,000 recordsmark 1 read: write 2 KBsame data, same edit, about 2,000 times less to clone and write
Size records by the unit you change. Cloning and writing happen per record, so one big record makes every small edit cost the whole inbox.

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.

js
// 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?

Raw API

indexedDB.open, request events, and transaction events

Your own small helpers around them

Pros

  • No dependency
  • Every feature, including the newest ones, the day it ships

Cons

  • Verbose callback code
  • Easy to miss an error or abort handler
  • Teams often end up rewriting idb by hand

idb

Promises over the exact same concepts

upgrade, blocked, blocking callbacks on openDB

Pros

  • About 1.2 KB (brotli)
  • Nothing new to learn: MDN docs still apply
  • Typed schema in TypeScript

Cons

  • No query builder or change notifications
  • You still own migrations, cross-tab updates, and batching

Dexie

Schema strings per version

Query builder, bulk operations, liveQuery

Pros

  • Much less code for typical apps
  • Reactive queries for React, Vue, Svelte, and Angular
  • Optional sync service (Dexie Cloud)

Cons

  • Larger bundle
  • Its own abstractions to learn and debug
  • Its version numbers map to IndexedDB's in a way you must understand when debugging

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.

Writing 1000 records, await db.put() per record, durability relaxed: transactions and commits 1000, awaits back to your code 1000, commits that wait for disk 0. Atomic: no: each record alone.transactions and commits1,000awaits back to your code1,000commits that wait for disk0atomic: no: each record alone
Bars use a log scale: each step to the right is ten times more. Every transaction pays a fixed cost and every await a smaller one, so the goal is the shortest bars that still keep each batch a sensible size.

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.
  • getAllKeys and count when 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 put update 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 put per pause is cheap; the debounce lesson covers the timing.
  • Flush when the page hides. On visibilitychange to hidden, start the pending save at once. Mobile browsers may kill a background tab without warning, and visibilitychange is 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.
A sequence diagram with the editor, the draft saver, and IndexedDB. The editor sends keystrokes to the saver. After an 800 millisecond pause, the saver puts the draft. IndexedDB replies complete, and the saver tells the editor Saved. Later the page fires visibilitychange hidden, and the saver puts the pending draft at once with strict durability. After a reload, the editor gets the draft by id from IndexedDB, and it is restored.editordraft saverIndexedDBkeystrokesput after 800 ms pausecompleteshow Savedvisibilitychange: hiddenput now, strictafter reload: get(draftId)draft restored
Debounce while typing, flush the moment the page hides, show 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 carry syncedAt.
  • A meta store for sync state: the server cursor or lastSync timestamp per collection, and the schema version of the cached data.
  • An outbox store with auto-increment keys: writes made offline, in order, waiting to be sent. Auto-increment gives first-in, first-out for free.
  • An attachments store for Blobs, kept apart so it can be cleaned up by size.
Inside the browser, for one origin: the UI renders from an in-memory cache. On start the memory cache is filled from IndexedDB. A sync worker fetches changes from the server and writes them to IndexedDB. A service worker serves the app shell (from the Cache API, not drawn) and reads the outbox from IndexedDB to send queued writes. IndexedDB holds the messages, drafts, and outbox stores.browser, one originUIrenders listsmemory cachewhat is on screenservice workershell, outboxsync workerfetch, then writeIndexedDBmessages, drafts, outboxserverload on start
Memory for what is on screen, IndexedDB for the durable copy every context can reach, the server as the source of truth. Workers do the heavy writes so the page stays responsive.

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

ProblemHow you noticeFix
Old tab blocks an upgradeUsers stuck on a spinner after a deployversionchange handler that closes and asks for a reload; blocked handler with a message
Quota exceededQuotaExceededError on writes, failed syncsEvict re-downloadable data first; check navigator.storage.estimate()
Data evicted by the browserUsers report empty drafts after weeks awayRequest persistent storage; treat local data as possibly missing (see client storage)
Transaction inactiveTransactionInactiveError in logsNo non-database await inside a transaction
Silent rollbacks"Saved" shown but data missingAwait tx.done or complete; listen to abort
Private windowsData gone after the window closesExpected: private modes keep IndexedDB only for the session
Schema drift with the serverCrashes reading old cached recordsVersion 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

  1. The stores and their keys. Write them like a schema: messages (key id, index [folderId, sentAt]), drafts (key id), outbox (auto-increment), attachments (key id, index messageId), meta.
  2. The read path. Render from memory, fill memory from IndexedDB on start, refresh from the server, write the changes back in one transaction.
  3. The write path. Optimistic update in memory, durable write to IndexedDB (drafts, outbox), then the network. Mention debounce and the flush on visibilitychange.
  4. The edges. Schema versions and the old-tab problem, quota errors and eviction, logout clearing data, and other tabs seeing the change.
LevelWhat a strong answer includes
JuniorKnows IndexedDB is async, stores objects and Blobs, holds far more than localStorage; picks it for offline data and drafts
MidDesigns stores, keys, and indexes for the screens; explains transactions, batching, and the auto-commit trap; uses idb or Dexie
SeniorPlans 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?

Question 2

A teammate's code throws TransactionInactiveError. What is the likely cause and fix?

Question 3

How do you ship a schema change to IndexedDB safely?

Question 4

How would you make draft saving reliable in a web editor?

Question 5

Syncing 20,000 records into IndexedDB is slow and freezes the page. How do you fix it?

Practice it

These design problems lean on indexeddb.

References

Read next