Understanding IndexedDB. The complete guide.

NikNik
Dec 12, 2023·
22 min read

Before we start

I have been working with IndexedDB for more than 3 years. When I just started, I thought, huh, it's similar to MongoDB, easy-peasy. Quite fast I understood that:

  1. The native API is not that easy to use, you need a handy wrapper like Dexie or idb with Promises from Jake Archibald

  2. It's quite easy to make IndexedDB slow, especially if you're using Dexie, but that's a separate story.

  3. Transactions are fragile

  4. and many other things

I used native iDB API, prepared the typing for iDB in flow (yup, I'm working in Meta), worked with Dexie and even worked on my own ORM. I kept finding some tricky yet interesting insights about IndexedDB.

This article is a complete "What do I know about IndexedDB" guide.

Get your coffee, bookmark the page, share / retweet!

❗️ All the examples are available in this codesandbox or github repo.

IndexedDB:

  1. is NoSQL and represents key-value storage DB;

  2. don't have fixed schema for ObjectStores:

    1. You can save something like: 1: {foo: 'bar'}, 2: {hello: 123}, 'world': {bar: {baz: ['hello', 123', 'world']}} where 1,2, and 'world' are primary keys;
  3. has indexed access. If you want to find the data by key, the key should be defined beforehand. If you try to filter data by non-indexed field you will receive an error:

    1. Note: you can read all the records or iterate through all the records but it will be extremely slow (O(n));
  4. is transaction based. You cannot access (neither read nor write) the DB outside the transaction;

  5. is async by design. Original IndexedDB API is callback-based, however, there are tons of promise-based wrappers;

  6. supports the work with blobs, binary formats (ArrayBuffers and so on);

  7. has LevelDB as a backend for iDB in Chrome and hence for V8-based browsers.

The structure of IndexedDB:

IndexedDB consists of:

  1. Database: the top level of IndexedDB. Each Database contains:

    1. Name: unique name of the DB for the definite domain;

    2. Version: DB versions are used for DB migrations. DB migration is a special type of transaction, named "version change transaction";

  2. Object Store: a "table" in your Database. An object store consists of a list of records, addressed by your key path.

    1. In addition to your key, you can have several indexes. Every Index will point out to your primary key in the object store (to avoid data duplicity)

  3. Keys: as ObjectStore is key-value storage, keys are used as a unique path to your values (similar to primary key).
    Single key:

    Compound keyPath:

  4. Indexes: each object store can have a list of many indexes associated with that store. Index refers to a list of fields of the objects in the store. Only the first level of objects is used.
    Indexes are used as a reference to a key, which allows IndexedDB to address the record itself.
    Index can be:

    1. Compound: consists of several fields

    2. Single: indexes single field in ObjectStore

    3. Unique: repetition is prohibited

    4. MultiEntry: support array indexation

IndexedDB cheatsheet schema:

Link: https://excalidraw.com/#json=fCE1ViGCCh-cmWt3vwLUw,6ruYd_5L1F-_dehB7XQAkQ

How to: create database

There is no separate API just to "create" a database. Everything starts with a connection. indexedDB.open creates a connection to the database. If there is no such database, it will be created.

indexedDB.open receives:

  • DB name: required field to identify the DB you want to be connected to

  • version: the version of the DB. This field is optional. If omitted, you still will be able to understand the current DB version and establish a connection.

To handle the result of the .open you should subscribe to the following events:

  • upgradeneeded: happens when the provided DB version is higher than existing, and therefore DB update is required. The handler of the event is a version change transaction, which can create/delete objectStores and indexes;

  • success: DB version change transaction is successful (if any) and the connection is established. In that case, you can access the DB through request.result;

  • error: connection attempt / version change transaction throws an error;

  • blocked: another code / tab maintains a connection to the DB with a smaller version number.

const request = indexedDB.open(DB_NAME: string, VERSION: number);

// Version change transaction
request.onupgradeneeded = () => {
  // DB connection for the transaction operation
  const db: IDBDatabase = request.result;
  // IDB transaction ref (version change transaction)
  const transaction: IDBTransaction = event.currentTarget.transaction;
};

request.onsuccess = () => {
   // establisehed connection to IDBDatabase  
  const db: IDBDatabase = request.result;
  db.version // contains the VERSION of the DB
};

request.onerror = (errorEvent) => {
  // Error during updating the db  
};

request.onblocked = () => {
 // DB is blocked 
 // (another codepath / tab maintains connection to old DB version)
};

Most of the real-life use cases open the database with version, as you want to ensure your code is up-to-date and has all keys, objectStores and indexes you need.

Async-await version of opening the connection to the DB:

const db: IDBDatabase = await openConnection('myDatabaseName', 4, (db) => {
   // version change transaction code goes here
});

function openConnection(dbName: string, version: number, upgradeTxn: (db: IDBDatabase) => void): Promise<IDBDatabase> {
    return new Promise((resolve,reject) => {
        const request = indexedDB.open(dbName, version);

        // Version change transaction
        request.onupgradeneeded = function (event) {
            upgradeTxn(request.result);
        }
        // Update completed successfully, DB connection is established
        request.onsuccess = () => {
          resolve(request.result);
        };

        // Something goes wrong during connection opening and / or 
        // during version upgrade
        request.onerror = (errorEvent) => {
          console.error('Cannot open db');
          reject(errorEvent);
        };

        // Stands for blocked DB 
        request.onblocked = () => {
          console.warn('Db is blocked');
          reject('db is blocked');
        };
    });
}

When the Database is created for the first time, we call .open with database name and (usually) with the 1 version of the DB. Note: you can use any number as a version of the database. As the DB doesn't exist yet, the new database will be created and your code will call a version change transaction.

📝 Version change transaction is a special type of transaction that can be accessed only when you create or upgrade the database version.

📝 Version change transaction has exclusive access to:

  • objectStores manipulation: create / delete

  • keys definition: simple / compound / autoincremental

  • indexes manipulation: create / delete / etc.

Once the transaction is completed, DB will have the new version set.

If there is an error during a transaction, the error event will be thrown, which can be caught using request.onerror handler.

If there is another connection to the same DB is maintained with smaller DB version number, the .open request will get blocked event thrown.

Overall schema of indexedDB.open:

Example of iDB creation with one objectStore and one additional index:

function openConnection(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("Understanding_IndexedDB", 1);

    // Version change transaction
    request.onupgradeneeded = function () {
      db = request.result; // IDBDatabase

      // Only version change transaction has access to create/delete object stores
      const namesObjectStore = db.createObjectStore("name-store", {
        keyPath: "id",
        // We can create auto-increment primary key
        autoIncrement: true,
      });
      // Only version change transaction has access to create/delete indexes
      namesObjectStore.createIndex("index[name]", "name", { unique: false });
    };

    // Will be called once onupgradeneeded is completed or 
    // when the DB already has correct version
    request.onsuccess = () => {
      LOG`DB: ${TEST_DB_NAME} created`;
      resolve(request.result); // return IDBDatabase
    };
    // Error in transaction
    request.onerror = (e) => {
      LOG`DB: ${TEST_DB_NAME} cannot be created: ${e}`;
      reject(e);
    };
    // Current DB is blocked
    request.onblocked = (e) => {
      LOG`DB: ${TEST_DB_NAME} is blocked: ${e}`;
      reject(e);
    };
  });
}

📝: The versioning for indexedDB.open can only be non-decreasing. If the version passed to indexedDB.open is less than the real DB version, it would throw an error.

It's extremely important!

📝 if you accidentally push a version, which breaks client processing, you cannot roll back changes. You have to do the fix forward. IndexedDB doesn't have "version rollback" transactions.

How to: create ObjectStore

ObjectStore can be created / deleted inside version change transaction only.

createObjectStore creates a new objectStore in your Database.

idbDatabase.createObjectStore(
  OBJECT_STORE_NAME: string, 
  KEY: {
    keyPath: string | Array<string>,
    autoIncrement: boolean = false // default
  }
);

As values are flexible, the minimal variant of createObjectStore receives only the object store name:

idbDatabase.createObjectStore('myNewObjectStore');

In that case, objects in the store won't have the key presented and will use keys separated from the values. It is also called out-of-line keys.

The second argument of createObjectStore allows you to define the key for the object store, and it's declared by an object with 2 fields:

  • keyPath: string or array of strings, which represents the "primary key" for key-value storage.

    • The key has to be unique. e.g. if you have keyPath defined for a person's name, you are about to receive an error as soon as you try to persist 2 persons with the same name.
  • autoIncrement: a flag that shows if the key is auto-incremental or not. The default is false.

const namesObjectStore = db.createObjectStore("name-store", {
  keyPath: "id",  
  autoIncrement: true,
});

You can use multiple fields as a keyPath:

db.createObjectStore("address-store", {
  // Complex keypath based on 6 keys:
  // NOTE: don't do keypath based on address, use it as index for production code ;)
  keyPath: ["country", "city", "street", "house", "flat", "zip"],
});

📝 The order of fields in KeyPath is important. That's exactly how the data will be presented in your DB.

To delete an object store, call deleteObjectStore :

idbDatabase.deleteObjectStore('myNewObjectStore');

How to: create an index

📝 Index is an additional way of accessing values in the object store. Technically index references the key, which refers to the value in the object store.

📝 Indexes creation / deletion can happen inside Version change transaction only.

To create an index you should have a reference to the object store:

objectStore.createIndex(
  UNIQUE_INDEX_NAME: string, 
  KEY_PATH: string | Array<string>, 
  OPTIONS: {
    // Shows if duplicate records for this index are allowed
    unique: boolean = false, 
    // Index can resolve entries inside array:
    multiEntry: boolean = false,
  }
);

The following code creates an index based on the name field. The index is marked as non-unique, and it might be omitted, as unique: false is the default value:

// Create an object store with out-of-line key:
const namesObjectStore = db.createObjectStore("name-store");
// Add index:
namesObjectStore.createIndex("index[name]", "name", { unique: false });

To create an index based on several fields we use Array<string> as a keyPath:

event.currentTarget.transaction
  .objectStore("name-store")
  .createIndex("index[name + lastName]", ["name", "lastName"], {
    unique: false,
  });

📝 The order of fields in KeyPath is important. That's exactly how the data will be indexed in your DB.

📝 sometimes, you may want to store an array with strings or numbers. And you might want to index the data based on this array. To achieve this, you can use an index with multiEntry option:

// Call inside onupgradeneeded callback:
event.currentTarget.transaction
  // select object store:
  .objectStore("name-store")
  // citizenship field is Array<String>, like: ['USA', 'UK', ...]
  .createIndex("index[citizenship]", "citizenship", {
    multiEntry: true,
  });

Example of records:

nameStore.add({ name: "John", lastName: "Doe", citizenship: ["USA"] });
nameStore.add({
  name: "Alice",
  lastName: "Smith",
  citizenship: ["USA", "UK"],
});
nameStore.add({
  name: "Bob",
  lastName: "Johnson",
  citizenship: ["USA", "Germany"],
});
nameStore.add({
  name: "Eva",
  lastName: "Anderson",
  citizenship: ["Greece"],
});

If we request data based on index[citizenship] field with USA as a request, we will receive John, Alice, and Bob, but not Eva.

How to: IDBTransaction

There are 3 types of IDBTransactions:

  1. 📝 readonly: only read requests. It's possible to have multiple readonly transactions opened to the same objectStore in the same time;

  2. 📝 readwrite: requires write access to the object store. You can have only 1 active transaction per time for the particular objectStore

  3. 📝 Version change: happens exclusively when the connection to the DB gets opened and the requested version is higher than the current DB version. This type of transaction has access to objectStores, indexes creation / deletion.

To create a transaction, you should have an active connection to the Database:

const transaction: IDBTransaction = (db: IDBDatabase)
  .transaction(Array<ObjectStoreName>, "readwrite" | "readonly");

📝 Transaction gives you access to ObjectStore (depending on transaction permissions to read / write operations):

// Open DB connection (if we didn't do it before)
const db: IDBDatabase = await openConnection("myTestDb", 4);

// Open readwrite transaction
const txn = db.transaction(["name-store", "address-store"], "readwrite");

// Request name-store objectStore
const nameStore: IDBObjectStore = txn.objectStore("name-store");

📝 Transaction is the only way to get/set the data:

const idbRequest: IDBRequest = nameStore.add({
  name: "Alice",
  lastName: "Smith",
  citizenship: ["USA", "UK"],
});

📝 You have direct control on when to abort (rollback) the transaction or when to commit it.

📝 txn.commit ends current transaction and persists the data from the IDBRequests you had:

// Open DB connection (if we didn't do it before)
const db: IDBDatabase = await openConnection("myTestDb", 4);
// ...
// Open readwrite transaction
const txn = db.transaction(["name-store", "address-store"], "readwrite");

// Request name-store objectStore
const nameStore: IDBObjectStore = txn.objectStore("name-store");

const idbRequest: IDBRequest = nameStore.add({
  name: "Alice",
  lastName: "Smith",
  citizenship: ["USA", "UK"],
});

// nameStore got new record Alice Smith
idbRequest.onsuccess = (e) => { 
  console.log("idb request success");
  txn.commit(); // Commits the current transaction
}

txn.oncomplete = (e) = {
  console.log("idb transaction is done");
}

txn.onabort = (e) = {
  console.log("idb transaction is aborted");
}

// Alice Smith is written to the db
// output:
// idb request success
// idb transaction is done

📝 txn.abort() closes the transaction and rolls back all the changes:

// Open DB connection (if we didn't do it before)
const db: IDBDatabase = await openConnection("myTestDb", 4);
// ...
// Open readwrite transaction
const txn = db.transaction(["name-store", "address-store"], "readwrite");

// Request name-store objectStore
const nameStore: IDBObjectStore = txn.objectStore("name-store");

const idbRequest: IDBRequest = nameStore.add({
  name: "Alice",
  lastName: "Smith",
  citizenship: ["USA", "UK"],
});

// nameStore got new record Alice Smith
idbRequest.onsuccess = (e) => { 
  console.log("idb request success");
  txn.commit(); // Commits the current transaction
}

txn.oncomplete = (e) = {
  console.log("idb transaction is done");
}

txn.onabort = (e) = {
  console.log("idb transaction is aborted");
}

// Alice Smith is NOT written to the db (rolled back)
// output:
// idb request success
// idb transaction is aborted

Transaction concurrency:

📝 Transactions are queued for the execution once indexedDB.transaction is requested.

📝 readwrite transactions for the same objectStore are executed consequently with the the order defined by .transaction requests:

const db: IDBDatabase = await openConnection("myTestDb", 4);
// ...
// 1st txn
const txn = db.transaction(["name-store", "address-store"], "readwrite");

// 2nd txn
const txn2 = db.transaction(["name-store"], "readwrite");

const nameStore2: IDBObjectStore = txn2.objectStore("name-store");

const idbRequest2: IDBRequest = nameStore2.add({name: "Alice 2"});

const nameStore: IDBObjectStore = txn.objectStore("name-store");

const idbRequest: IDBRequest = nameStore.add({name: "Alice"});

// Alice will be written first
// Alice2 will be written after the first transaction gets completed

📝 readonly transaction can be executed concurrently and the order is not determined in that case:

// 1st txn
const txn = db.transaction(["name-store", "address-store"], "readonly");

// 2nd txn
const txn2 = db.transaction(["name-store", "address-store"], "readonly");

const nameStore2: IDBObjectStore = txn2.objectStore("name-store");
const nameStore: IDBObjectStore = txn.objectStore("name-store");

const request2 = nameStore2.getAll();

const request = nameStore.getAll();

txn2.oncomplete = (e) => {
  LOG`The 2nd transaction is done`;
  LOG`${request2.result}`;
  LOG`First request state: ${request.readyState}`;
};

txn2.onabort = (e) => {
  LOG`idb transaction is aborted`;
};

txn.oncomplete = (e) => {
  LOG`The 1st transaction is done`;
  LOG`${request.result}`;
  LOG`Second request state: ${request2.readyState}`;
};

txn.onabort = (e) => {
  LOG`idb transaction is aborted`;
};

📝 If you have 2 transactions: readonly and readwrite, they will be executed in order of .transaction .

📝 Several readwrite transactions for the different objectStores are executed concurrently.

const txn = db.transaction(["address-store"], "readwrite");
const txn2 = db.transaction(["name-store"], "readwrite");

const addressStore: IDBObjectStore = txn.objectStore("address-store");
const nameStore2: IDBObjectStore = txn2.objectStore("name-store");

nameStore2.add({name: "Alice 2"});

const clearRequest = addressStore.clear();
clearRequest.onsuccess = () => {
  addAddresses(addressStore); // adds 10+ records to the db
};

txn2.oncomplete = (e) => {
  LOG`name-store, 2nd transaction is done`;
};

txn.oncomplete = (e) => {
  LOG`address-store, 1st transaction is done`;
};

// txn2 will be completed sooner, than txn in most of the cases, 
// despite we start txn first. However the order is not strict here.
// (just becasue, txn2 has too many things to do)

📝 Rules above are scaled the same way if multiple tabs are opened for the same domain (only one readwrite transaction to a single objectStore, and etc).

Transaction lifetime:

📝 Transaction is maintained by browser until one of these events:

  • transaction.commit() is called. It will end transaction and commit all the changes. Any pending IDBRequest will be completed with success, IDBTransaction will be completed with complete event

  • No any pending IDBRequest / success callbacks in the next macro task call of the browser event loop.

    • In that case, as no other work is planned, transaction is treated as successfully completed and IndexedDB behaviour is the same, as per .commit() call.

    • IMPORTANT: transaction still will be maintained in case of microtasks.

  • transaction.abort() is called. In that case all the changes will be rolled back. IDBRequest ends with abort event which bubbles to IDBTransaction

  • Runtime Errors which prevent transaction to be committed (changes won't be saved):

    • Uncaught exception in the IDBRequest success/error handler.

    • I/O error:

      • Fail to write on disk;

      • Quota exceeded;

      • OS failure;

      • Hardware failure;

    • Browser crash / etc;

    • Fail to write data due to constrains (e.g. unique index / key);

📝 If for some reason, you want to keep transaction active for some period of time, you should keep creating empty IDBRequest, to ensure next event loop ticks have pending IDBRequest:

const txn = db.transaction(["name-store"], "readwrite");
LOG`Try to click other commands, like fillDb! This transaction keeps readwrite lock`;

const nameStore: IDBObjectStore = txn.objectStore("name-store");

let endTransaction = false;
const startTime = Date.now();
setTimeout(() => (endTransaction = true), 5000);
let idbRequestCycles = 0;

function cycleIdbRequest() {
  if (endTransaction) {
    txn.commit();
    return;
  }
  idbRequestCycles++;
  // request non-existing item
  const idbRequest = nameStore.get(Infinity);
  idbRequest.onsuccess = cycleIdbRequest;
}

cycleIdbRequest();
txn.oncomplete = (e) => {
  LOG`Transaction is completed after ${
    (Date.now() - startTime) / 1000
  } sec. In total ${idbRequestCycles} IDBRequests were created`;
};

Such a code creates lots of empty, chained IDBRequest queries and keeps transaction alive, while the browser is not entirely blocked (however, it consumes a lot of CPU). Example of output:

📝: It "might" look sometimes a good idea to "keep" transaction alive in that way, when something is calculated in a background, but keep in mind:

  1. It consumes a lot of CPU

  2. Your transaction will be executed slower, if you keep lots of IDBRequests inside the same IDBTransaction (in other words, for long-running IDB transactions, you can see performance degradation).

📝 If you have transaction alive, and keep spawning micro tasks with no any pending IDBRequest, the transaction will remain alive (however the browser's thread will be blocked too):

const txn = db.transaction(["name-store"], "readwrite");
const startTime = Date.now();
function cyclePromiseLoop(): Promise<void> {
  if (Date.now() - startTime > 2000) {
    txn.objectStore("name-store").count().onsuccess = (event) => {       
      const result = event.target.result;
      LOG`IDBRequest ended after ${
        (Date.now() - startTime) / 1000
      } sec. Result: ${result}`;
    };
    txn.commit();
    return Promise.resolve();
  }
  return Promise.resolve().then(cyclePromiseLoop);
}

await cyclePromiseLoop();
txn.oncomplete = (e) => {
  LOG`Transaction is completed after ${(Date.now() - startTime) / 1000} sec.`;
};

IDBRequest will be executed successfully:

📝 If the next macrotask starts with no pending IDBRequest or idbRequest.onsuccess handlers, the transaction will be completed.

Simple way to verify is to put IDBRequest inside setTimeout (macrotask). You can see that the transaction will be closed before you start IDBRequest:

const txn = db.transaction(["name-store"], "readonly");

setTimeout(() => {
  const nameStore = txn.objectStore("name-store");
  try {
    const read = nameStore.count();
    read.onerror = (e) => {
      LOG`Error performing request: ${e}`;
    };
    read.onsuccess = () => {
      LOG`result: ${read.result}`;
    };
  } catch (e) {
    // This code will be executed (as transaction is not active)
    LOG`Runtime Error performing request: ${e}`;
  }
}, 0);

txn.oncomplete = (e) => {
  LOG`Transaction is completed`;
};

txn.onabort = (e) => {
  LOG`idb transaction is aborted`;
};

That code results in constant error, because the transaction is not active:

Transaction durability:

📝 There are 2 types of transaction durability:

  • strict: transaction will be considered commited if and only if the data is persisted on disk

  • relaxed: browser marks transaction as soon as changes get written to the operating system. "Physically" the data might be written later.

In manual tests the difference between strict and relaxed durability might be huge (up to tens milliseconds per transaction). Run "Relaxed vs strict durability" test to see example:

function txn(durability: "strict" | "relaxed"): Promise<void> {
  return new Promise((resolve) => {
    const txn = idb.transaction(["name-store"], "readwrite", {
      durability,
    });

    const nameStore = txn.objectStore("name-store");
    nameStore.add({
      name: `Name #${Math.trunc(Math.random() * 1000)}`,
    });
    txn.oncomplete = () => {
      resolve();
    };
  });
}

LOG`Strict durability, 100 runs`;
let startTime = performance.now();
await Promise.all(Array.from({ length: 100 }).map(() => txn("strict")));
LOG`Strict: ${performance.now() - startTime} ms`;
LOG`Relaxed durability, 100 runs`;
startTime = performance.now();
await Promise.all(Array.from({ length: 100 }).map(() => txn("relaxed")));
LOG`Relaxed: ${performance.now() - startTime} ms`;

For 100 runs the difference would be huge:

Strict durability, 100 runs
Strict: 2278 ms // ~ 22ms per transaction
Relaxed durability, 100 runs
Relaxed: 39 ms // ~0.39ms per transaction

📝 Transaction durability can be defined when you request a transaction:

const txn = db.transaction(["name-store"], "readwrite", {
  durability: "relaxed", // or "strict"
});

📝 Firefox and Chrome process transactions differently. Firefox uses relaxed durability by default, while Chrome is using strict. Generally, relaxed durability is reliable enough (unless OS / hardware issues).

How to: IDBObjectStore

The entity that is "analog" for tables in Databases is ObjectStore.

ObjectStore contains all the methods and incapsulates all the processing for the data.

📝 ObjectStore is accessed from the transaction that requested the lock for that particular ObjectStore:

const txn = db.transaction(["name-store"], "readwrite");
const nameStore: IDBObjectStore = txn.objectStore("name-store");

You cannot request an access to ObjectStore which you don't declare in the transaction:

const txn = db.transaction(["name-store"], "readwrite");
const addressStore = txn.objectStore("address-store"); // ERROR!

Object store consist in:

  • Objects, which are presented by keys and values. Usually key is a part of value, but sometimes you can use out-of-line, which are not stored in value.

  • Key: presented by either single field / several fields or out-of-line key

  • Indexes: an additional way of accessing values in your object store. Technically index references the key, which refers to the value in object store.

IDBObjectStore provides all the API to:

  • Request data

  • Write / re-write data

  • Iterate through data: cursors, indexes. You also can mutate / delete data while iterating.

📝 IDBObjectStore operations are ordered. If you subsequentely call methods, they are guaranteed to be executed in the order they were planned:

const txn = idb.transaction(["name-store"], "readwrite");

const nameStore = txn.objectStore("name-store");
// We call method sequentially, 
// but IDBRequest are planned without
// awaiting the end of previous one:
nameStore.clear();
nameStore.add({
  name: `Mark`,
  lastName: `Smith`,
});
nameStore.count().onsuccess = (e) => {
  // You can find that TS typing is not the best for IndexedDB
  // @ts-expect-error 
  LOG`1st Count is: ${e.target.result}`;
};
nameStore.put({ name: "Alice" });
nameStore.count().onsuccess = (e) => {
  // @ts-expect-error
  LOG`2st Count is: ${e.target.result}`;
};
nameStore.clear();
nameStore.count().onsuccess = (e) => {
  // @ts-expect-error
  LOG`3st Count is: ${e.target.result}`;
};

// Output:
// 1st Count is: 1
// 2st Count is: 2
// 3st Count is: 0

IDBObjectStore methods from readonly / readwrite transactions:

  • .add(VALUE, ?KEY) stores the provided VALUE in the ObjectStore. Returns an IDBRequest . Works only within readwrite transaction

    • 📝 Browser is using structured clone (deep clone) to make a copy of the value to persist.
  • .put(item, ?key): updates an object by provided key, or adds a new object if there is no object in the provided key. Similar to .add but able to update the data stored, not only to add items. Works in readwrite transaction;

  • .clear(): removes all the data from the ObjectStore. Returns an IDBRequest . Works only inside readwrite transaction;

  • .count(key | KeyRange): returns the IDBRequest, which would store in result the number of elements, matched to provided key value or KeyRange. If no arguments is provided, it will returns the overall amount of items in ObjectStore;

  • .delete(key | KeyRange): removes the data matched to key or within KeyRange . Only for readwrite transaction;

  • .get(key): fetches the data matched provided key. Returns an IDBRequest, which, once completed will have the data in .result field;

  • .getAll(?key | KeyRange, ?count): returns the IDBRequest, which would store in .result the array of objects, matched to provided key value or KeyRange. If no arguments is provided, it will returns all items in the ObjectStore. count is used to limit amount of data returned (Like LIMIT in SQL);

  • .getAllKeys(?Key | KeyRange, ?count): similar to .getAll but returns keys, instead of data;

  • .getKey(key | KeyRange): similar to .get but returns key, instead of data;

  • .index(name): opens an index (if exists, otherwise throws an error). returns IDBIndex which allows to request data from index.

IDBObjectStore in version change transaction:

When we upgrade IDBDatabase version, we have access to ObjectStores and hence we have 2 additional methods:

  • .createIndex: creates an index

  • .deleteIndex: removes an index

IDBObjectStore methods which work with IDBCursor:

Sometimes it's better to open a cursor and iterate through the data instead of fetching all data at once / trying to fetch data first and then update it in separate IDBRequest.

  1. .openCursor(key | KeyRange, direction): returns an IDBRequest which has IDBCursorWithValue in result. the cursor can iterate through data and manipulate it. The level of access from IDBCursor depends on the transaction type (readonly / readwrite). Possible directions:

    1. next: increasing order of keys. Iterates through all records;

    2. nextunique: increasing order of keys. Ignores duplicates;

    3. prev: decreasing order. Goes through all records;

    4. prevunique: decreasing order, ignores duplicates;

  2. .openKeyCursor(key | KeyRange, direction): same but iterates only for keys;

How to: IDBCursor and IDBCursorWithValue

📝 Cursors allows you to iterate through data record by record, allowing you to effectively mutate the data without multiple separated IDB requests.

📝 Cursor can be one of 2 types: IDBCursor or IDBCursorWithValue. .openCursor method always returns IDBCursorWithValue. IDBCursorWithValue has .value field which keeps the value at the cursor position.

Methods:

  • .continue(): moves cursor to the next position;

  • .advance(N): similar to continue, but moves the cursor for N positions;

  • .continuePrimaryKey(key, primaryKey): sets the cursor to the given index key and primary key given as arguments;

  • .delete(): deletes the entry at the cursor position. Returns IDBRequest

  • .update(value): updates the value at the cursor position. Returns IDBRequest

Cursor processing example:

const txn = idb.transaction(["name-store"], "readwrite");
const nameStore = txn.objectStore("name-store");

let mutatedEntries = 0;
let deletedEntries = 0;
let total = 0;
// Iterate through all the entries
const request = nameStore.openCursor();
request.onsuccess = () => {
  const cursor = request.result;
  if (cursor) {
    total++;
    if (cursor.value.name.startsWith("N")) {
      deletedEntries++;
      cursor.delete();
    } else {
      if (!cursor.value.updated) {
        const newValue = { ...cursor.value, updated: true };
        mutatedEntries++;
        cursor.update(newValue);
      }
    }
    cursor.continue();
  }
};

txn.oncomplete = () => {
  LOG`Transaction is completed. Deleted: ${deletedEntries}, mutated: ${mutatedEntries}, total: ${total}`;
};

You don't have an access to update / delete methods if you are in readonly transaction, however, you still have an access to opening cursors and iterating through data.

How to: IDBKeyRange

It's common case, when you need to fetch only part of the data. In that case, iterating through all items and filtering them is not the fastest solution.

It's better to use KeyRanges. KeyRange allows you to define the search interval for your keys or indexes. MDN already has an excellent table with the example:

RangeCode
Keys >= xIDBKeyRange.lowerBound(x)
Keys > xIDBKeyRange.lowerBound(x, true)
Keys <= xIDBKeyRange.upperBound(x)
Keys < xIDBKeyRange.upperBound(x, true)
y>= Keys >= xIDBKeyRange.bound(x, y)
y> Keys > xIDBKeyRange.bound(x, y, true, true)
y> Keys > xIDBKeyRange.bound(x, y, true, true)
y>= Keys > xIDBKeyRange.bound(x, y, true, false)
y> Keys >= xIDBKeyRange.bound(x, y, false, true)
only xjust use x as a key or IDBKeyRange.only(z)

How to: IDBIndex

📝 Provides access to index inside ObjectStore. IDBIndex has all IDBObjectStore methods to read the data with the same API, including IDBCursor(WithValue) requests: count(), get(), getAll(), getAllKeys(), getKey(), openCursor(), openKeyCursor().

End-of-article tips!

📝 IndexedDB database is unique by domain. Meaning if you have several sub-domains, you will have separate DBs per domain;

📝 Transaction commit/abort methods don't work for upgrade transaction;

📝 DB connection is not guaranteed to be maintained the whole page lifetime. The DB connection can be lost. You should be subscribed to .onclose event and re-store connection either lazily or proactively as soon as you loose connection

📝 Sometimes you can receive (in sentry or user reports) issues, that Database in not accessible. It's known problem and for users it can be solved only by re-loading the browser (quit and reopen).

📝 As IndexedDB makes structured clone every time you persist / read the data

77
Subscribe to my newsletter

Read articles from Nik directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nik
Nik

Software engineer @Meta, WhatsApp, building secure, e2ee messaging experience for users. ex-HH.ru ex-Program committee member HolyJS conference