JavaScript IndexedDB

Now, let’s explore a much more powerful built-in database than localStorage: IndexedDB.

It can have almost any value and multiple key types. Transactions are also supported for more reliability. Key range queries and indexes are supported by IndexedDB. Moreover, it can store a lot more data than the localStorage. As a rule, IndexedDB is used for offline applications, to be connected to ServiceWorkers and other technologies.

Opening Database

To begin the work with IndexedDB, it is, first, necessary to open a database.

The syntax is as follows:

let openReq = indexedDB.open(name, version);

A lot of databases can exist with different names. But, note that all of them should be in the current origin. Websites have the capability of accessing the databases of one another.

The next step is listening to events on the openRequest request, like here:

  • success: the database is arranged, there exists the “database object” in openRequest.result, which you should apply for the next calls.
  • error: the opening is declined.
  • upgradeneeded: database is arranged, but its version is out-of-date.

There is a built-in mechanism of “schema versioning” in IndexedDB, that doesn’t exist in server-side databases.

IndexedDB is considered client-side. The data is kept in the browser, hence the developers can’t access it directly.

But, while publishing a new version of the app, it can be necessary to update the database.

In case the local database version is less than specified in the open, then a unique upgradeneeded event occurs. So, the versions can be compared, and data structures can be upgraded if required.

The event can occur when there is no database yet, so initialization may be performed.

So, the first step is publishing the app, opening it with version 1, and performing initialization in the upgradeneeded handler, like this:

let openRequest = indexedDB.open("store", 1);
openRequest.onupgradeneeded = function () {
  // triggers if the client does not have a database
  // initialization
};
openRequest.onerror = function () {
  console.error("Error", openRequest.error);
};
openRequest.onsuccess = function () {
  let db = openRequest.result;
  // continue working with the database using the db object
};

Publishing the 2nd version will look like this:

let openRequest = indexedDB.open("store", 2);
openRequest.onupgradeneeded = function () {
  // the existing version of the database is less than 2 (or it does not exist)     let db = openRequest.result;
  switch (db.version) { // existing version of the database
    case 0:
      // version 0 means the client did not have a database
      // initialization
    case 1:
      // client had version 1, update
  }
};

After openRequest.onsuccess, in openRequest.result there is the database object, which can be used for further operations.

Let’s check out how to delete the database:

let deleteReq = indexedDB.deleteDatabase(name)
// deleteReq.onsuccess/onerror tracks the result

Object Store

An object store is necessary for storing something in IndexedDB. It is a primary concept of IndexedDB, and its counterparts in other databases are collections and tables. Multiple stores can exist in a database. Any value, even complex objects, can be stored in it. IndexedDB applies the standard serialization algorithm for cloning and storing an object.

It is similar to JSON.stringify but can store more data types. For each value in the store, there should be a specific key. A key should include a type one of the following: string, date, number, binary, or array.

In case it is a specific identifier, the values can be searched, removed, and updated by the key.

It is illustrated in the picture below:

Now, let’s see how to create an object store in JavaScript.

Here is the syntax:

db.createObjectStore(name[, keyOptions]);

This is a synchronous operation, and no await is necessary. If keyOptions is not supplied, then the key should be provided while storing an object.

For example, the object store below applies id as a key, like this:

db.createObjectStore('lessons', {
  keyPath: 'id'
});

Note that you can create or modify an object store only while updating the version of DB within the upgradeneeded handler.

For deleting an object store, you can act like this:

db.deleteObjectStore('lessons');

Transactions

In different kinds of databases, you can use the term “transaction”. Generally, it’s a group of operations that can either totally succeed or totally fail. The overall data operations more must be done within a transaction in IndexedDB.

This is how you can start the transaction:

db.transaction(store[, type]);
  • store: it is the name of the store that the transaction intends to access.
  • type: it is the type of the transaction. It can be one of the following:
    1. readonly: is capable of reading only.
    2. readwrite: can both read and write data. But, can’t create, remove, or alter object stores.

There is also another type of transactions: versionchange. It can do anything, but you can’t generate it manually.

A versionchange transaction can be automatically created by IndexedDB while opening the database for the updateneeded handler. After creating the transaction, it is necessary to add an item to the store as follows:

let transaction = db.transaction("lessons", "readwrite"); // (1)
// get the store of object to operate on it
let lessons = transaction.objectStore("lessons"); // (2)
let lesson = {
  id: 'js',
  price: 20,
  created: new Date()
};
let request = lessons.add(lesson); // (3)
request.onsuccess = function () { // (4)
  console.log("Lesson added to the store", request.result);
};
request.onerror = function () {
  console.log("Error", request.error);
};

There exist four basic steps:

  1. Creating a transaction, mentioning all the stores it intends to access (1).
  2. Getting the store object with transaction.objectStore(name) at (2).
  3. Performing the request to the object store books.add(book)at (3).
  4. Handling the request success or error (4). Afterward, another request can be made.

Transactions’ Autocommit

Once all the transaction requests are over, and the queue of microtasks is empty, it will be automatically committed.

It can be assumed that a transaction can commit once all the requests are complete, and the current code ends.

In the example, mentioned above, no specific code is required to end the transaction.

The auto-commit principle of transactions has a significant disadvantage: it is not capable of inserting an async operation like setTimeout or fetch in the middle of the transaction.

Let’s check out another code where request2 fails in the (*) line as the transaction is committed already, and no requests can be made:

let request1 = lessons.add(lesson);
request1.onsuccess = function () {
  fetch('/').then(response => {
    let request2 = lessons.add(anotherLesson); // (*)
    request2.onerror = function () {
      console.log(request2.error.name); // TransactionInactiveError
    };
  });
};

The reason is that the fetch is considered an asynchronous operation, a macrotask. So, the transactions will be closed before the browser begins to do macrotasks.

In the example above, it is also possible to create a new db.transaction before the new (*) request.

But, first, it is necessary to do fetch, preparing the data, and then generate a transaction.

Also, you can listen to the transaction.oncompleteevent for detecting the moment of successful completion:

let transaction = db.transaction("lessons", "readwrite");
// operations
transaction.oncomplete = function () {
  console.log("Transaction is completed");
};

The transaction can be manually aborted by calling the following: transaction.abort();

It will cancel all the modifications made by the requests and trigger the transaction.onabort event.

Error Handling

Any failed request will automatically abort the transaction and cancel all the changes.

But, there are circumstances when you want to handle the failure without aborting the existing changes. It can be possible with the request.onerror handler. It can preclude the transaction abort with the help of the event.preventDefault() call.

Here is an example:

let transaction = db.transaction("lessons", "readwrite");
let lesson = {
  id: 'js',
  price: 20
};
let request = transaction.objectStore("lessonss").add(book);
request.onerror = function (event) {
  // ConstraintError occurs when an object with the id already exists
  if (request.error.name == "ConstraintError") {
    console.log("Lesson with such id already exists"); // handle the error
    event.preventDefault(); // do not interrupt transaction
    // use a different key for the book?
  } else {
    // unexpected error, can't handle it
    // transaction will be aborted
  }
};
transaction.onabort = function () {
  console.log("Error", transaction.error);
};

Event Delegation

Instead of using onerror and onsuccess on each request, you can use event delegation.

The Indexed DB events are capable of bubbling: request → transaction → database.

All the events belong to the DOM. Hence, all the errors can be caught by the db.onerrorhandler like this:

db.onerror = function (event) {
  let request = event.target; // error request
  console.log("Error", request.error);
};

It is possible to stop bubbling with event.stopPropagation() inside request.onerror, like this:

request.onerror = function (event) {
  if (request.error.name == "ConstraintError") {
    console.log("Lesson with such id already exists"); // handle the error
    event.preventDefault(); // don't abort the transaction
    event.stopPropagation(); // don't bubble error up, "chew" it
  } else {
    // nothing to do
    // transaction will be aborted
    // we can take care of the error in the transaction.onabort
  }
};

Searching by Keys

In an object store, you can implement a search of two main types:

  1. By a range or by a key. In other words, by lesson.id in the books’ storage.
  2. By a different object field.

Let’s start at dealing with the keys and key ranges (1).

The methods involving the searching support ( exact keys or range queries) are known as IDBKeyRange. It indicates a key range.

You can create ranges with the help of the following calls:

  • IDBKeyRange.lowerBound(lower, [open]) considers: ≥lower (or >lower if open is true)
  • IDBKeyRange.upperBound(upper, [open]) considers: ≤upper (or <upper if open is true)
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) considers: between lower and upper. In case the open flags are considered true, the matching key is not involved within the range.
  • IDBKeyRange.only(key)– a range, which comprises only a single key, that is used not often.

The overall searching methods allow a query argument, which may either represent a key range or an exact key:

  • Istore.get(query) – looking for the first value by a key or a range.
  • Istore.getAll([query], [count]) – looking for the overall values, limit by count if specified.
  • Istore.getKey(query) – looking for the first key, which can satisfy the query. As a rule, it’s a range.
  • Istore.getAllKeys([query], [count]) – looking for all the keys that can satisfy the query. As a rule, a range, up to count if specified.
  • Istore.count([query]) – receiving the total count of keys that can satisfy the query. As a rule, a range.

Let’s get to the example. Imagine, there are lots of books in the store. The id field will be the key, hence all the methods can be looked for by id. So, the request example will look like this:

//receive one book
lessons.get('js')
 
// receive lessons with 'html' <= id <= 'css'
lessons.getAll(IDBKeyRange.bound('html', 'css'))
 
// receive books with id < 'html'
lessons.getAll(IDBKeyRange.upperBound('css', true))
 
// receive all lessons
lessons.getAll()
 
// receive all keys: id > 'js'
lessons.getAllKeys(IDBKeyRange.lowerBound('js', true))

Deleting from the Store

The delete method searches for the values for deletion by a query. The format of the calls is like getAll.

delete(query): it deletes the corresponding values by the query.

An example of using the delete method is as follows:

// delete the lesson with id='js'
lessons.delete('js');

In case, you wish to delete the books on the bases of the price or another object field, then it’s necessary to detect the key in the index, calling delete like this:

// find the key where price = 10
let request = priceIndex.getKey(10);
request.onsuccess = function () {
  let id = request.result;
  let deleteRequest = lessons.delete(id);
};

For the deletion of everything, you can act as shown below:

lessons.clear(); // clear the storage.

Cursors

As you know, the getAll/getAllKeys methods return an array of values/keys. But, if an object storage is larger than the available memory, getAll won’t be able to receive all the records as an array.

In such cases, cursors can become a helping hand.

A cursor is a specific object, which traverses the object storage, given a query, returning a single key/value at a time. So, it can save memory.

The syntax of the cursor is the following:

// like getAll, but with the cursor:
let request = store.openCursor(query, [direction]);
// to get keys, not values (getAllKeys): store.openKeyCursor

The primary difference of the cursor is that request.onsuccess occurs several times: one time for every result.

Let’s check out an example of using a cursor:

let transaction = db.transaction("lessons");
let lessons = transaction.objectStore("lessons");
let request = lessons.openCursor();
// called for each lesson found by the cursor
request.onsuccess = function () {
  let cursor = request.result;
  if (cursor) {
    let key = cursor.key; // lesson key, id field
    let value = cursor.value; // lesson object
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more lessons");
  }
};

Here are the primary cursor methods:

  • advance(count) – advancing the cursor count times, skipping values.
  • continue([key]) – advancing the cursor to the next value in range corresponding (or right after key if specified).

No matter there exist more values or corresponding to the cursor or not - onsuccess will be called. Afterward, in the cursor.key, it will be possible to receive the cursor by pointing to the next record, or there will be undefined. It is, also, possible to create a cursor over an index.

For the over cursor indexes, the index key is cursor.key. For the object key, cursor.primaryKey should be used:

let request = priceIdx.openCursor(IDBKeyRange.upperBound(10));
// call for each record
requests.onsuccess = function () {
  let cursor = requests.result;
  if (cursor) {
    let key = cursor.primaryKey; // next object store key, id field
    let value = cursor.value; // next object store object, lesson object
    let key = cursor.key; // next index key, price
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more lessons");
  }
};

Example:

In case the memory is short for the data, a cursor can be used.

<!DOCTYPE html>
<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/idb@3.0.2/build/idb.min.js">
      </head>
      <body>
    </script>
    <button onclick="addLesson()">Add a lesson</button>
    <button onclick="clearLessons()">Clear lessons</button>
    <p>Lessons list:</p>
    <ul id="listElem"></ul>
    <script>
      let db;
      init();
      
      async function init() {
        db = await idb.openDb('lessonsDb', 1, db => {
          db.createObjectStore('lessons', {keyPath: 'name'});
        });
        list();
      }
      
      async function list() {
        let tx = db.transaction('lessons');
        let lessonStore = tx.objectStore('lessons');
      
        let lessons = await lessonStore.getAll();
      
        if (lessons.length) {
          listElem.innerHTML = lessons.map(lesson => `<li>
              name: ${lesson.name}, price: ${lesson.price}
            </li>`).join('');
        } else {
          listElem.innerHTML = '<li>No lessons yet. Please add lessons.</li>'
        }
      }
      
      async function clearLessons() {
        let tx = db.transaction('lessons', 'readwrite');
        await tx.objectStore('lessons').clear();
        await list();
      }
      
      async function addLesson() {
        let name = prompt("Lesson name?");
        let price = +prompt("Lesson price?");
        let tx = db.transaction('lessons', 'readwrite');
      
        try {
          await tx.objectStore('lessons').add({name, price});
          await list();
        } catch(err) {
          if (err.name == 'ConstraintError') {
            alert("Such lesson exists already");
            await addLesson();
          } else {
            throw err;
          }
        }
      }
      window.addEventListener('unhandledrejection', event => {
        alert("Error: " + event.reason.message);
      });
      
    </script>
    </body>
</html>

Summary

IndexedDB is a straightforward key-value database that is robust enough for offline applications. At the same time, it’s quite easy in usage.

Let’s describe its basic usage with several sentences.

  • It gets a promise wrapper like idb.
  • A database can be opened with idb.openDb(name, version, onupgradeneeded).
  • For the requests are initiated: Creation of a transaction db.transaction('books') Receiving the object store transaction.objectStore('books').
  • Afterward, comes the search by a key that can call methods on the object store directly.
  • In case the memory is short for the data, a cursor can be used.



Do you find this helpful?

Related articles