IndexedDB Explained: Schema, Transactions, and Cursors
Learn how browsers store, query, and sync large amounts of data offline
IndexedDB is a low-level , nosql database that is built into the browser. It provides powerful database-like features such as transactions, key ranges, and indexes. IndexedDB is intended for offline apps, to be combined with ServiceWorkers and other technologies. Some important features are
- It can store almost any kind of values, multiple key types.
- It can store much bigger volume of data than localstorage.
- It allows to search for records based on ranges of keys, not just exact matches.
- It uses transactions to ensure that all read/write operations are atomic, consistent, and fail-safe.
Opening a database
For making operations in IndexedDb , we first need to open a database.
Syntax is —
const request = IndexedDb.open(name , version);- name — a string, database name
- version — a positive integer version, by default , it is 1.
We can have different databases with different names attached with particular origin.
The call returns IDBOpenDbRequest object with a result or error value that we can handle.
request.onerror = function(){
console.log("error :" + request.error);
}let database;
request.onsuccess = function(){
database = request.result;
}IndexedDb has built-in mechanism of “schema versioning”, which is mostly absent in client level database. If current database has schema version less than specified while opening the database , then a special event upgradeneeded is triggered ,and we can compare the version and upgrade the data structure as needed.
let openRequest = indexedDB.open("store", 2);openRequest.onupgradeneeded = function(event) {
// the existing database version is less than 2
let db = openRequest.result;
switch(event.oldVersion) { // existing db version
case 0:
// version 0 means that the client had no database
// perform initialization
case 1:
// client had version 1
// update
}
};To delete a database:
let deleteRequest = indexedDB.deleteDatabase(name);Parallel Update Problem
Parallel Update Problem occurs when multiple tabs of same origin try to open indexedDb with different version, causing the upgrade process in one tab to be blocked by the open connection in another tab.
In order to resolve that, we can use versionchange event that triggers on the “outdated” database object. When this event occurs, we should listen for it and close the old database connection so the upgrade can proceed.
let openRequest = indexedDB.open("store", 2);openRequest.onsuccess = function() {
let db = openRequest.result;
db.onversionchange = function() {
db.close();
alert("Database is outdated, please reload the page.")
};
};Structuring the database
IndexedDB uses object stores, which are equivalent to tables in SQL or collections in MongoDB. It may have muliple stores.
The syntax to create an object store:
db.createObjectStore(name, [keyOptions]);This operation is synchronous, no await needed.
nameis the store name, e.g."books"for books,keyOptionsis an optional object with one of two properties:
- Key Path (keypath) – a path to an object property that IndexedDB will use as the key.
- Key Generator (autoIncrement) — if true, then the key for a newly stored object is generated automatically, as an ever-incrementing number.
We can store almost any value, including complex objects. IndexedDb uses standard serialization algorithm to clone and store an object. But some objects like an object with circular reference can not be stored.
There must be a unique key for every value in the store. A key must be one of these types — number, date, string, binary, or array. It is a unique identifier, so we can search, remove and update values by the key.
One limitation is object store can only be created or modified while updating the db version in upgradedneeded handler.
Adding , retrieving, and removing data
Transaction is a group of operation in which either all of them are successful or failed. All data operations must be made within a transaction in IndexedDB.
To start a transaction:
db.transaction(store, [type]);storeis a store name that the transaction is going to access. Can be an array of store names if we’re going to access multiple stores.type– a transaction type, one of:
1. readonly – can only read, the default.
2. readwrite – can only read and write the data, but not create/remove/alter object stores.
const transaction = db.transaction(["customers"], "readwrite");
// when all data is added to the database.
transaction.oncomplete = (event) => {
console.log("All done!");
};
transaction.onerror = (event) => {
// Handle errors
};
const objectStore = transaction.objectStore("users");
userData.forEach((user) => {
const request = objectStore.add(user);
request.onsuccess = (event) => {
// event.target.result === customer.id;
};
});Note —Multiple read-only transaction can access the store at the same time. However, read-write transaction are exclusive. When a read-write transaction starts, it locks the store for writing, and any subsequent transaction must wait until it finishes.
Object stores support two methods to store a value:
- put(value, [key]) Add the
valueto the store. Thekeyis supplied only if the object store did not havekeyPathorautoIncrementoption. If there’s already a value with the same key, it will be replaced. - add(value, [key]) Same as
put, but if there’s already a value with the same key, then the request fails, and an error with the name"ConstraintError"is generated.
Similar to opening a database, we can send a request: books.add(book), and then wait for success/error events.
A transaction is automatically committed when all its requests finish and the microtask queue is empty.
Get Think & Build’s stories in your inbox
Join Medium for free to get updates from this writer.
Because of this behavior:
- You cannot include async operations like
fetch,setTimeout, or other macrotasks inside a transaction. - If you do, the transaction will already be committed by the time the async callback runs, causing errors like
TransactionInactiveError.
let tx = db.transaction("books", "readwrite");
let store = tx.objectStore("books");
store.add({ id: "js" });
fetch("/book-info").then(res => {
store.add({ id: "js-2" }); // TransactionInactiveError
});- If a request fails, the transaction automatically aborts, rolling back all changes.
- You can prevent this by calling
event.preventDefault()inrequest.onerrorif the error is handled.
const tx = db.transaction("books", "readwrite");
const store = tx.objectStore("books");
const req = store.add({ id: "js", price: 10 });
req.onerror = (event) => {
if (req.error.name === "ConstraintError") {
console.log("Duplicate book, skipping");
event.preventDefault(); // continue transaction
}
};Searching in IndexedDB Object Stores
IndexedDB provides powerful ways to query data stored in object stores. Broadly, there are two approaches to searching:
- Search by primary key or key range
- Search by other object fields using indexes
Searching by Key or Key Range
Each object store is organized around a primary key, which allows efficient lookups using either an exact key or a range of keys.
Key-based Search Methods
IndexedDB exposes several methods for querying by key:
store.get(query)— Retrieves the first record that matches the key or key range.store.getAll(query, count)— Retrieves all matching records. Results can be limited using the optionalcountparameter.store.getKey(query)— Returns the first key that satisfies the given query (commonly used with ranges).store.getAllKeys(query, count)— Retrieves all keys matching the query, optionally limited bycount.store.count(query)— Returns the total number of records that match the query.
These methods accept either a single key or a key range as their argument.
Defining Key Ranges with IDBKeyRange
Key ranges allow querying a continuous set of keys using IDBKeyRange:
IDBKeyRange.lowerBound(lower, open)— Matches keys greater than or equal tolower
(greater thanlowerifopenistrue)IDBKeyRange.upperBound(upper, open)— Matches keys less than or equal toupper
(less thanupperifopenistrue)IDBKeyRange.bound(lower, upper, lowerOpen, upperOpen)— Matches keys betweenlowerandupper, inclusive or exclusive depending on the open flagsIDBKeyRange.only(key)— Matches exactly one key (rarely needed in practice)
Searching by Other Object Fields Using Indexes
Searching by fields other than the primary key (for example, price or timestamp) requires an index.
An index is an auxiliary data structure attached to an object store. It tracks a specific object property and maps each value of that property to the primary keys of matching records. This enables fast lookups without scanning the entire store.
Indexes must be created during the database upgrade phase (onupgradeneeded):
objectStore.createIndex(name, keyPath, options);name— A unique name for the indexkeyPath —The object property the index should trackoptions(optional):
- unique — Ensures that each indexed value is unique. Attempts to insert duplicates will result in an error.
- multiEntry — Used when the indexed property is an array. When enabled, each element of the array is indexed separately, allowing searches by individual values instead of the entire array.
Cursors
A cursor is a special iterator that walks through records in an object store (or index) one record at a time, instead of loading everything into memory at once.
Because object stores are internally sorted by key, cursors traverse records in a predictable order (ascending by default).
The syntax:
let request = store.openCursor(query, [direction]);query— a key orIDBKeyRange(same asgetAll)direction— is an optional argument
Options are —
"next"– ascending order (default)"prev"– descending order"nextunique","prevunique"– skips duplicate index keys (used with indexes)
Unlike getAll(), a cursor request fires multiple times—once for each matching record.
Cursor Control Methods
cursor.continue([key])— Moves to the next record (or jumps to the specified key)cursor.advance(count)— Skips the nextcountrecords
An example of querying using cursor —
function iterateCheapBooks(maxPrice) {
const tx = db.transaction("books", "readonly");
const index = tx.objectStore("books").index("price_idx");
const range = IDBKeyRange.upperBound(maxPrice);
const request = index.openCursor(range);
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
console.log({
price: cursor.key,
id: cursor.primaryKey,
book: cursor.value,
});
cursor.continue();
} else {
console.log("No more cheap books");
}
};
}Conclusion
Mastering IndexedDB enables you to build powerful, offline-first web applications with reliable data storage.Understanding transactions, indexes, and cursors helps you manage large datasets safely and efficiently. With these concepts, you can create scalable, resilient apps that perform well even without a network.
Thanks for reading — and as always,
Happy coding! 🚀







