cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

IndexedDB

Store structured data in the browser with IndexedDB: object stores, keys, transactions, indexes, cursors, version upgrades, and small promise wrappers.

By the end, you can
  • 01
    Design a browser databaseChoose a database, object stores, keys, and indexes for structured records.
  • 02
    Use transactions safelyGroup reads and writes, handle complete and abort events, and avoid inactive transactions.
  • 03
    Wrap the event APITurn requests and transactions into promises without changing IndexedDB’s rules.

A real database in the browser

IndexedDB is the browser’s built-in database for structured data. It is not a string-only shelf like localStorage. It stores JavaScript values with the structured clone algorithm, runs asynchronously with events, and can hold enough data for serious offline features in most browsers.

You use IndexedDB when an app needs to keep many records between visits: drafts, messages waiting to sync, product catalogs, map tiles, user-generated files, or an offline reading list. It also works in workers, so heavier storage work does not have to block the main page.

Definition

IndexedDB is an origin-scoped, asynchronous browser database made of named databases, object stores, records, keys, indexes, and transactions.

Real-life analogyIndexedDB is a filing cabinet

Imagine a browser tab opening a filing cabinet owned by this website. The database is the cabinet. Each object store is a drawer. A record is a folder in a drawer. The key is the folder label. An index is a second tab list, so you can find the same folders by author or ISBN without reading every folder.

In real life: The whole cabinet
In JavaScript: A database, such as cf-demo-indexeddb
In real life: A drawer
In JavaScript: An object store, such as books
In real life: A folder
In JavaScript: One stored record object
In real life: The label on a folder
In JavaScript: The key, from a keyPath or out-of-line key
In real life: Alphabetical tabs by author
In JavaScript: An index, such as author

Where the analogy stops: A filing cabinet is physical and usually single-user. IndexedDB is asynchronous, versioned, and multiple tabs can hold connections at once.

IndexedDB’s vocabulary sounds large, so this lesson builds one piece at a time: first the cabinet, then a teller-like transaction, then indexes and cursors, then version upgrades, and finally tiny promise helpers.

Databases & object stores

INTERACTIVE

You start with indexedDB.open(name, version). It returns an IDBOpenDBRequest immediately. The browser later fires onupgradeneeded if the database needs schema work, then onsuccess when a connection is ready, or onerror/onblocked when it cannot proceed.

Object stores are where records live. You choose how keys are made: a keyPath reads a property from the stored object, an out-of-line key is passed separately to add(value, key), and autoIncrement can generate numeric keys. Keys can be numbers, strings, Dates, arrays, or binary values.

Step through open → upgrade → transaction
Step 0 of 9Ready
Your turn: follow the blue line

This replay is a small model of the IndexedDB event order. The playgrounds below run the real browser API and should show the same shape: open, maybe upgrade, success, transaction, request, complete.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 request.onupgradeneeded = (event) => {  const db = request.result;  db.createObjectStore("books", { keyPath: "id", autoIncrement: true });}; request.onsuccess = () => {  const db = request.result;  const tx = db.transaction("books", "readwrite");  const store = tx.objectStore("books");  store.put({ title: "Eloquent JavaScript", author: "Marijn" });  tx.oncomplete = () => db.close();};
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Which visit is this?

Predict first: which handler is skipped on the return visit?

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Filing cabinet playground: add, get, put, delete
Visible API shapeJavaScript
const request = indexedDB.open("cf-demo-indexeddb", 2);request.onupgradeneeded = () => {  const store = db.createObjectStore("books", {    keyPath: "id",    autoIncrement: true,  });  store.createIndex("author", "author", { unique: false });  store.createIndex("isbn", "isbn", { unique: true });}; store.add(book);    // fail if key existsstore.put(book);    // insert or replacestore.get(1);store.delete(1);store.getAll();
Real databaseopening

Store: books · keyPath: id · autoIncrement: true

      Try it yourself

      This uses the real IndexedDB in your browser. Reset deletes cf-demo-indexeddb, recreates it, and cleanup deletes it again when this component unmounts.

      The object store uses keyPath: id and autoIncrement: true. Try Duplicate add, then Put id 1: add fails for an existing key; put replaces.

      Notice the difference between add and put. add means “create a new folder only.” If the key already exists, the request fails with ConstraintError. put means “insert or replace.” That makes put useful for saves and add useful when duplicates must be rejected.

      Structured clone, not JSON

      IndexedDB stores values with the structured clone algorithm. Dates, Maps, arrays, typed data, and Blobs are okay. Functions and DOM nodes are not data snapshots, so they throw DataCloneError. Store records, not live page elements or behavior.

      Transactions

      LAB

      A transaction is the unit of work. You open one with selected store names and a mode: readonly for reading, readwrite for changing data, and the special versionchange mode during upgrades. Every request belongs to a transaction.

      Real-life analogyA transaction is a bank teller session

      If you transfer money, you do not want “money left Ada” without “money reached Grace.” A readwrite IndexedDB transaction gives you the same all-or-nothing promise for the stores it controls.

      In real life: Walk up to one teller
      In JavaScript: Start one transaction
      In real life: Withdraw and deposit in the same session
      In JavaScript: Queue related requests
      In real life: Teller stamps complete
      In JavaScript: transaction.oncomplete commits
      In real life: Something goes wrong, papers are voided
      In JavaScript: abort rolls writes back

      Where the analogy stops: A human teller waits while you make a phone call. IndexedDB transactions do not: if there are no pending requests and control returns to the event loop, the transaction may auto-commit.

      Transactions lab: all-or-nothing transfer
      Transfer and inactive trapJavaScript
      const tx = db.transaction("accounts", "readwrite");const store = tx.objectStore("accounts");const ada = await promisifyRequest(store.get("ada")); await new Promise((resolve) => setTimeout(resolve, 0)); store.put({ ...ada, balance: ada.balance - 10 });// TransactionInactiveError: the transaction had no pending IndexedDB requests.
      Transaction eventsabort path
        Try it yourself

        A readwrite transaction is like one teller session. If it aborts midway, earlier writes roll back. If you await a non-IndexedDB timer while no requests are pending, the transaction can close.

        The trap demonstrates TransactionInactiveError in real browsers that enforce transaction lifetime this way.

        The sneaky rule is lifetime. Transactions auto-commit when their request queue is empty and the browser gets control back. Awaiting an unrelated timer, network request, or any non-IndexedDB promise in the middle can let the transaction finish. The next store.put then throws TransactionInactiveError in browsers that enforce the rule.

        Common transaction events
        EventMeaningWhat to do
        oncompleteAll requests finished and writes committed.Update UI, close the connection if you are done.
        onabortThe transaction was canceled or a request failed without being handled.Tell the user nothing changed; inspect transaction.error.
        onerrorA request error bubbled to the transaction.Handle or abort deliberately.
        commit()In newer browsers, asks to commit early when no more requests will be added.Optional optimization, not required for correctness.

        Indexes & cursors

        QUERY

        The primary key finds a record by its label. Indexes add other lookup paths. In the playground, author is non-unique because one author can write many books, while isbn is unique because two books should not share the same ISBN.

        IDBKeyRange.only(value) means exactly this key. IDBKeyRange.bound(low, high) means between two keys. IDBKeyRange.lowerBound(value) means this key and everything after it. For a batch, getAll(range, count) is compact. For one-at-a-time work, openCursor(range, direction) returns a cursor; call cursor.continue() to move.

        Indexes and cursors: second ways to find records
        Index and cursor codeJavaScript
        const tx = db.transaction("books", "readonly");const byAuthor = tx.objectStore("books").index("author");const range = IDBKeyRange.only("Ada Lovelace");const matches = await promisifyRequest(byAuthor.getAll(range, 3)); const cursorRequest = byAuthor.openCursor(null, "prev");cursorRequest.onsuccess = () => {  const cursor = cursorRequest.result;  if (!cursor) return;  console.log(cursor.key, cursor.value.title);  cursor.continue();};
        Query logreal IndexedDB
          Try it yourself

          Indexes are lookup paths. Key ranges narrow the path, cursors walk records one at a time, and getAll(range, count) grabs a limited batch.

          The author index is non-unique, so Ada can have two books. The isbn index is unique, so two matching ISBNs would fail.
          Which IndexedDB piece?
          • One named container called recipe-app for all saved app data
          • A books drawer that stores book records
          • Find every book by author without scanning all records
          • Subtract stock and add an order as one all-or-nothing step
          • One named container called offline-reader
          • The accounts collection keyed by username
          • Look up a book by unique isbn
          • Move 10 credits from Ada to Grace, or move none
          Try it yourself
          0 of 8 correct

          Sort each realistic task into the piece that should handle it.

          Choose a category for every card. You can change an answer at any time; Reset clears them all.

          Versioning

          UPGRADE

          The second argument to indexedDB.open is a database version number. You can only create or delete object stores and indexes during the onupgradeneeded event, inside its versionchange transaction. That is where you remodel the cabinet.

          Real-life analogyVersioning is remodeling the cabinet

          You cannot add a drawer while people are actively filing papers. IndexedDB has the same rule: every open connection must agree to the new version before schema changes finish.

          In real life: Old cabinet layout
          In JavaScript: oldVersion
          In real life: New cabinet layout
          In JavaScript: newVersion / db.version
          In real life: Add a drawer while remodeling
          In JavaScript: createObjectStore in onupgradeneeded
          In real life: Everyone must step away first
          In JavaScript: Open tabs receive versionchange; upgrades may be blocked

          Where the analogy stops: A remodel can happen once and be done. Web users may have old tabs open for hours, so production apps must handle blocked upgrades kindly.

          Versioning: remodel the cabinet
          Upgrade with blocked/versionchangeJavaScript
          const first = indexedDB.open("cf-demo-indexeddb-versioning", 1);first.onsuccess = () => {  const db1 = first.result;  db1.onversionchange = () => db1.close();   const second = indexedDB.open("cf-demo-indexeddb-versioning", 2);  second.onblocked = () => console.log("waiting for db1");  second.onupgradeneeded = (event) => {    console.log(event.oldVersion, second.result.version);    second.transaction.objectStore("notes").createIndex("tag", "tag");  };};
          Version eventsv1 → v2
            Try it yourself

            Schema changes happen only in onupgradeneeded. If one connection is still open, the upgrade can be blocked until that connection closes after versionchange.

            This simulates two tabs with two connections on one page.

            Real apps usually listen for db.onversionchange and close the old connection, then ask the user to reload. They also handle openRequest.onblocked so the upgrade is not silently stuck.

            Promise wrappers

            ASYNC/AWAIT

            IndexedDB is event-based. A request has onsuccess and onerror; a transaction has oncomplete, onabort, and onerror. Small wrappers let you use the Promisification and async & await lessons without hiding the transaction rules.

            Two tiny helpersPop out in the code editor (opens in a new tab)JavaScript
            function promisifyRequest(request) {  return new Promise((resolve, reject) => {    request.onsuccess = () => resolve(request.result);    request.onerror = () => reject(request.error);  });} function txDone(tx) {  return new Promise((resolve, reject) => {    tx.oncomplete = () => resolve();    tx.onabort = () => reject(tx.error);    tx.onerror = () => reject(tx.error);  });} async function saveBook(db, book) {  const tx = db.transaction("books", "readwrite");  const id = await promisifyRequest(tx.objectStore("books").add(book));  await txDone(tx);  return id;}
            Promise wrappers: write async/await around requests
            promisifyRequest and txDonePop out in the code editor (opens in a new tab)JavaScript
            function promisifyRequest(request) {  return new Promise((resolve, reject) => {    request.onsuccess = () => resolve(request.result);    request.onerror = () => reject(request.error);  });} function txDone(tx) {  return new Promise((resolve, reject) => {    tx.oncomplete = () => resolve();    tx.onabort = () => reject(tx.error);    tx.onerror = () => reject(tx.error);  });} async function saveBook(db, book) {  const tx = db.transaction("books", "readwrite");  const id = await promisifyRequest(tx.objectStore("books").add(book));  await txDone(tx);  return id;}
            Async resultrequest + tx.done
              Try it yourself

              The wrapper does not make IndexedDB synchronous; it only converts request events into promise settlement so your code can read top to bottom.

              Libraries such as idb package these patterns nicely. The lesson writes tiny helpers so you can see the rule.

              Libraries such as idb provide polished promise wrappers and convenience helpers. They are excellent in production. Learning the tiny version first helps you understand why awaiting IndexedDB requests is fine, while awaiting an unrelated timer inside a transaction is a trap.

              Where you’ll use this

              IndexedDB shines when the browser needs a local source of truth. An offline notes app might store drafts in a notes store, attach a updatedAt index for sync, and keep attachments as Blobs. A shop might cache a product catalog and queue checkout attempts while the network is unreliable.

              A practical shapePop out in the code editor (opens in a new tab)JavaScript
              async function queueDraft(db, draft) {  const tx = db.transaction(["drafts", "syncQueue"], "readwrite");  const drafts = tx.objectStore("drafts");  const queue = tx.objectStore("syncQueue");   const key = await promisifyRequest(drafts.put(draft));  await promisifyRequest(queue.add({ type: "draft", key, createdAt: new Date() }));  await txDone(tx);}

              The important design choice is the transaction boundary: saving the draft and queueing the sync job belong together. If one fails, the app should not pretend the other happened.

              Common misconceptions

              • “IndexedDB is just localStorage with objects.” No. It is asynchronous, transactional, indexed, and structured-clone based.
              • “Opening returns the database.” No. Opening returns a request; the connection appears later on request.result.
              • “I can create stores whenever I want.” No. Schema changes belong in onupgradeneeded.
              • “A transaction waits while I await anything.” No. Awaiting non-IDB work can let the transaction finish.
              • “Indexes store duplicate data I must keep in sync.” No. The browser maintains indexes for a store.
              • “Structured clone stores functions.” No. Functions and DOM nodes throw DataCloneError.
              IndexedDB compared with localStorage
              FeatureIndexedDBlocalStorage/sessionStorage
              Data shapeStructured values: objects, Dates, Maps, BlobsStrings only; JSON is your job
              API styleAsynchronous requests and transactionsSynchronous getters and setters
              LookupPrimary keys, indexes, ranges, cursorsOnly by string key
              Best forMany records, offline apps, files, sync queuesSmall settings and simple flags

              Practice exercises

              5 EXERCISES
              Exercise 1 · Warm-upPredict add vs put

              Use the model to remember the difference between new-only and replace-or-insert writes.

              Starter codePop out in the code editor (opens in a new tab)JavaScript
              const existing = new Map([[1, "Ada"]]);
              const add = existing.has(1) ? "ConstraintError" : "stored";
              existing.set(1, "Grace");
              console.log(add + " / " + existing.get(1));

              Answer, then press Check. Spacing and letter case don’t matter.

                Exercise 2 · Warm-upChoose the key strategy

                A store saves objects shaped like { id: 7, title: "Draft" }. Which option makes IndexedDB read the key from id?

                Answer, then press Check. Spacing and letter case don’t matter.

                  Exercise 3 · PracticeAbort the transfer

                  In the transfer lab, the transaction debits Ada and then aborts before crediting Grace. How many balances should change?

                  Answer, then press Check. Spacing and letter case don’t matter.

                    Exercise 4 · PracticeRead a key range

                    Use this small array model to predict what an IDBKeyRange.bound(2020, 2021) style query would include.

                    Starter codePop out in the code editor (opens in a new tab)JavaScript
                    const years = [2019, 2020, 2021, 2024];
                    const matches = years.filter((year) => year >= 2020 && year <= 2021);
                    console.log(matches.join(", "));

                    Answer, then press Check. Spacing and letter case don’t matter.

                      Exercise 5 · ChallengeName the wrapper

                      You want to write const book = await helper(store.get(1));. What helper from the lesson turns the request into a promise?

                      Answer, then press Check. Spacing and letter case don’t matter.

                        Check your understanding

                        7 QUESTIONS
                        IndexedDB quiz · 7 questionsScore: first tries count
                        1. Question 1 of 7What does indexedDB.open(name, version) return immediately?

                          Choose an answer to see the explanation.

                        2. Question 2 of 7What does this model print?

                          Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                          const existing = new Map([[1, "Ada"]]);
                          const add = existing.has(1) ? "ConstraintError" : "stored";
                          existing.set(1, "Grace");
                          console.log(add + " / " + existing.get(1));

                          Choose an answer to see the explanation.

                        3. Question 3 of 7When may code create or delete object stores and indexes?

                          Choose an answer to see the explanation.

                        4. Question 4 of 7What is the safest description of a readwrite transaction?

                          Choose an answer to see the explanation.

                        5. Question 5 of 7What years does this bounded range model print?

                          Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                          const years = [2019, 2020, 2021, 2024];
                          const matches = years.filter((year) => year >= 2020 && year <= 2021);
                          console.log(matches.join(", "));

                          Choose an answer to see the explanation.

                        6. Question 6 of 7Why is await new Promise(resolve => setTimeout(resolve, 0)) dangerous inside a transaction?

                          Choose an answer to see the explanation.

                        7. Question 7 of 7Which values can IndexedDB store with structured clone?

                          Choose an answer to see the explanation.

                        Key takeaways

                        • Open returns a request; schema setup happens in onupgradeneeded; success gives you a connection.
                        • Object stores hold records, keys identify records, and indexes provide additional lookup paths.
                        • Transactions are all-or-nothing and auto-commit when their request queue empties.
                        • Key ranges, cursors, and getAll let you query without loading everything blindly.
                        • Promise wrappers make code pleasant, but they do not change IndexedDB’s event-loop lifetime rules.

                        Final definition: IndexedDB is the browser’s transactional, indexed, structured-data database for apps that need more than string storage.

                        Up next: Cache API & file storage.

                        CompleteFrontend Clear concepts. Working examples.