</> HTML5Advent
ENFRESDEITPT

// apis

IndexedDB tutorial: build a notes app that works offline

A step-by-step IndexedDB tutorial. Open a database, create an object store, add and read records, query by index, delete entries, and handle version upgrades - all in one small notes app you can run in the browser.

A person standing in front of glass-walled server racks in a data center, holding an open laptop, with blue lighting and vendor logos visible on the glass panels

The concept guide explains what IndexedDB is and when to reach for it. This tutorial builds a small notes application from scratch so you can see every step: opening a database, creating a store, writing records, reading them back, querying by index, and deleting entries.

Everything runs in the browser. No build tools, no server, no dependencies. Copy each block into a single HTML file and open it.

Step 1: open the database

Every IndexedDB interaction starts with indexedDB.open(). You pass a name and a version number. If the database does not exist yet, or if your version is higher than the one on disk, the browser fires upgradeneeded.

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('notes-app', 1);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('notes')) {
        const store = db.createObjectStore('notes', {
          keyPath: 'id',
          autoIncrement: true,
        });
        store.createIndex('by_tag', 'tag', { unique: false });
        store.createIndex('by_date', 'createdAt', { unique: false });
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

A few things to notice. keyPath: 'id' tells IndexedDB which property on each object is the primary key. autoIncrement: true makes the database generate that key for you. The two createIndex calls let you query notes by tag or by date later, without scanning every record.

Step 2: add a record

Writing to IndexedDB always happens inside a transaction. You open one on the object store you need, get a reference to that store, and call add() or put().

async function addNote(db, text, tag) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    const record = {
      text,
      tag: tag || 'general',
      createdAt: new Date().toISOString(),
    };
    const request = store.add(record);
    request.onsuccess = () => resolve(request.result); // the generated id
    request.onerror = () => reject(request.error);
  });
}

add() throws if a record with the same key already exists. put() overwrites instead. For new records, add() is safer because it catches accidental duplicates.

Step 3: read all records

To read everything in a store, open a readonly transaction and call getAll():

async function getAllNotes(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readonly');
    const store = tx.objectStore('notes');
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

If you only need one record and you know its key, use store.get(key) instead. It returns a single object rather than an array.

Close-up of a dark-themed code editor on a monitor, showing Ruby configuration code with green and white syntax highlighting, a file tree on the left and line numbers on the right
Writing code in a dark editor. IndexedDB follows the same request-and-callback pattern visible in most browser APIs, wrapped here in promises for readability.

Step 4: query by index

Indexes let you find records without scanning the entire store. To get every note tagged "work":

async function getNotesByTag(db, tag) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readonly');
    const store = tx.objectStore('notes');
    const index = store.index('by_tag');
    const request = index.getAll(tag);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

You can also use a key range to query a span of values. For example, to get notes created after a certain date:

const range = IDBKeyRange.lowerBound('2026-01-01T00:00:00.000Z');
const request = store.index('by_date').getAll(range);

The four range constructors are lowerBound, upperBound, bound (both ends) and only (exact match). Each accepts an optional boolean to exclude the boundary value.

Step 5: update a record

Updating uses put(). You must include the key property (id in our schema) so IndexedDB knows which record to overwrite:

async function updateNote(db, id, newText) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    const getReq = store.get(id);
    getReq.onsuccess = () => {
      const note = getReq.result;
      if (!note) { reject(new Error('Not found')); return; }
      note.text = newText;
      const putReq = store.put(note);
      putReq.onsuccess = () => resolve();
      putReq.onerror = () => reject(putReq.error);
    };
    getReq.onerror = () => reject(getReq.error);
  });
}

Step 6: delete a record

Call store.delete(key) inside a readwrite transaction:

async function deleteNote(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    const request = store.delete(id);
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

To clear every record in a store, use store.clear() instead.

Step 7: handle version upgrades

When you need to add a new index or a new object store after users already have version 1, bump the version number and handle the migration in onupgradeneeded:

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const oldVersion = event.oldVersion;

  if (oldVersion < 1) {
    const store = db.createObjectStore('notes', {
      keyPath: 'id',
      autoIncrement: true,
    });
    store.createIndex('by_tag', 'tag', { unique: false });
    store.createIndex('by_date', 'createdAt', { unique: false });
  }

  if (oldVersion < 2) {
    // version 2: add a "priority" index
    const tx = event.target.transaction;
    const store = tx.objectStore('notes');
    store.createIndex('by_priority', 'priority', { unique: false });
  }
};

Check event.oldVersion to run only the migrations the user has not seen yet. A user upgrading from 0 (no database) runs both blocks. A user upgrading from 1 runs only the second.

Putting it all together

Here is a minimal script that exercises every function above:

(async () => {
  const db = await openDB();

  const id = await addNote(db, 'Buy groceries', 'personal');
  console.log('Added note with id:', id);

  const all = await getAllNotes(db);
  console.log('All notes:', all);

  const tagged = await getNotesByTag(db, 'personal');
  console.log('Personal notes:', tagged);

  await updateNote(db, id, 'Buy groceries and cook dinner');
  console.log('Updated note', id);

  await deleteNote(db, id);
  console.log('Deleted note', id);
})();

Open the browser console to see the output. Every operation is asynchronous, every write goes through a transaction, and the data survives page reloads without any server.

Common mistakes

MistakeWhat happensFix
Creating a store outside onupgradeneededInvalidStateErrorAll schema changes go in onupgradeneeded
Using await inside a transaction callbackTransaction auto-closes before the await resolvesKeep all store operations synchronous within one transaction tick, or open a new transaction after the await
Forgetting to bump the version numberonupgradeneeded never firesIncrement the version integer each time you change the schema
Passing a string where a key expects a numberRecord not foundMatch the type: if autoIncrement generates numbers, query with a number

IndexedDB is verbose, but every piece serves a purpose: the version gates your schema, transactions protect your data, and indexes keep reads fast. Once these seven steps feel routine, you have everything you need to store structured data on the client, whether for offline support, caching, or keeping state that outlasts the session.

Frequently asked questions

How do I open an IndexedDB database?
Call indexedDB.open(name, version). The first argument is a string that names the database, and the second is an integer version number. The call returns an IDBOpenDBRequest. Listen for its onsuccess event to get the database handle, and for onupgradeneeded to create or modify object stores when the version changes.
What is onupgradeneeded in IndexedDB?
onupgradeneeded fires when the browser has no database with that name yet, or when the version number you pass is higher than the one on disk. It is the only place where you can create or delete object stores and indexes. Code that tries to do so outside this event will throw.
Can IndexedDB work offline?
Yes. IndexedDB stores data in the browser with no network required. Paired with a service worker that caches your HTML, CSS and JavaScript, you get a fully offline-capable application. The data persists across sessions until the user clears site data or storage pressure causes the browser to evict it.
How is IndexedDB different from localStorage?
localStorage is synchronous, stores only strings, and is capped around 5 MB. IndexedDB is asynchronous, stores structured data including Blobs and ArrayBuffers, supports indexes and transactions, and can hold hundreds of megabytes. Use localStorage for a few small flags; use IndexedDB for anything larger or structured.