// 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.
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.
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
| Mistake | What happens | Fix |
|---|---|---|
Creating a store outside onupgradeneeded | InvalidStateError | All schema changes go in onupgradeneeded |
Using await inside a transaction callback | Transaction auto-closes before the await resolves | Keep all store operations synchronous within one transaction tick, or open a new transaction after the await |
| Forgetting to bump the version number | onupgradeneeded never fires | Increment the version integer each time you change the schema |
| Passing a string where a key expects a number | Record not found | Match 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.