Working with IndexedDB

A guide to the basics of IndexedDB.

This guide covers the basics of the IndexedDB API. We are using Jake Archibald's IndexedDB Promised library, which is very similar to the IndexedDB API, but uses promises (which you can await for a more succinct syntax). This simplifies the API while maintaining its structure.

What is IndexedDB?

IndexedDB is a large-scale, NoSQL storage system that allows storage of just about anything in the user's browser. In addition to the usual search, get, and put actions, IndexedDB also supports transactions. Here is the definition of IndexedDB on MDN:

IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files/blobs. This API uses indexes to enable high performance searches of this data. While DOM Storage is useful for storing smaller amounts of data, it is less useful for storing larger amounts of structured data. IndexedDB provides a solution.

Each IndexedDB database is unique to an origin (typically, this is the site domain or subdomain), meaning it cannot access or be accessed by any other origin. Data storage limits are usually quite large, if they exist at all, but different browsers handle limits and data eviction differently. See the Further reading section for more information.

IndexedDB terms

Database
The highest level of IndexedDB. It contains the object stores, which in turn contain the data you would like to persist. You can create multiple databases with whatever names you choose.
Object store
An individual bucket to store data. You can think of object stores as being similar to tables in traditional relational databases. Typically, there is one object store for each type (not JavaScript data type) of data you are storing. For example, given an app that persists blog posts and user profiles, you could imagine two object stores. Unlike tables in traditional databases, the actual JavaScript data types of data within the store do not need to be consistent (for example, if there are three people in the people object store, their age properties could be 53, 'twenty-five', and unknown ).
Index
A kind of object store for organizing data in another object store (called the reference object store) by an individual property of the data. The index is used to retrieve records in the object store by this property. For example, if you're storing people, you may want to fetch them later by their name, age, or favorite animal.
Operation
An interaction with the database.
Transaction
A wrapper around an operation, or group of operations, that ensures database integrity. If one of the actions within a transaction fails, none of them are applied and the database returns to the state it was in before the transaction began. All read or write operations in IndexedDB must be part of a transaction. This allows for atomic read-modify-write operations without having to worry about other threads acting on the database at the same time.
Cursor
A mechanism for iterating over multiple records in a database.

How to check for IndexedDB support

IndexedDB is almost universally supported. However, if you're working with older browsers, it's not a bad idea to feature-detect support just in case. The easiest way is to check the window object:

function indexedDBStuff () {
  // Check for IndexedDB support:
  if (!('indexedDB' in window)) {
    // Can't use IndexedDB
    console.log("This browser doesn't support IndexedDB");
    return;
  } else {
    // Do IndexedDB stuff here:
    // ...
  }
}

// Run IndexedDB code:
indexedDBStuff();

How to open a database

With IndexedDB, you can create multiple databases with any names you choose. If a database doesn't exist when attempting to open it, it will automatically created. To open a database, you can use the openDB() method using the idb library:

import {openDB} from 'idb';

async function useDB () {
  // Returns a promise, which makes `idb` usable with async/await.
  const dbPromise = await openDB('example-database', version, events);
}

useDB();

This method returns a promise that resolves to a database object. When using the openDB(), method you provide a name, version number, and an events object to set up the database.

Here is an example of the openDB() method in context:

import {openDB} from 'idb';

async function useDB () {
  // Opens the first version of the 'test-db1' database.
  // If the database does not exist, it will be created.
  const dbPromise = await openDB('test-db1', 1);
}

useDB();

You place the check for IndexedDB support at the top of the anonymous function. This exits out of the function if the browser doesn't support IndexedDB. Then you call the openDB() method to open a database named 'test-db1'. In this example, the optional events object has been left out to keep things simple—but you will eventually need to specify it to do any meaningful work with IndexedDB.

How to work with object stores

An IndexedDB database contains one or more object stores. The concept of an object store is similar to that of a table in a SQL database. Like SQL tables, an object store contains rows and columns, but in IndexedDB, there is less flexibility in the number of columns in that an IndexedDB object store contains a column for a key, and another column for the data associated with that key.

Create object stores

Take, for example, a site persisting user profiles and notes, you can imagine a people object store containing person objects, and a notes object store. A well structured IndexedDB database should have one object store for each type of data that needs to be persisted.

To ensure database integrity, object stores can only be created and removed in the events object in an openDB() call. The events object exposes a upgrade() method which provides a way to create object stores. Call the createObjectStore() method in the upgrade() method to create the object store:

import {openDB} from 'idb';

async function createStoreInDB () {
  const dbPromise = await openDB('example-database', 1, {
    upgrade (db) {
      // Creates an object store:
      db.createObjectStore('storeName', options);
    }
  });
}

createStoreInDB();

This method takes the name of the object store as well as an optional configuration object that lets you define various properties for the object store.

The following is an example of how the createObjectStore() method is used:

import {openDB} from 'idb';

async function createStoreInDB () {
  const dbPromise = await openDB('test-db1', 1, {
    upgrade (db) {
      console.log('Creating a new object store...');

      // Checks if the object store exists:
      if (!db.objectStoreNames.contains('people')) {
        // If the object store does not exist, create it:
        db.createObjectStore('people');
      }
    }
  });
}

createStoreInDB();

In this example, an events object is passed to the openDB() method in order to create the object store, and like before, the work of creating the object store is done in the event object's upgrade() method. However, the browser will throw an error if you try to create an object store that already exists, so you wrap the createObjectStore() method in an if statement that checks if the object store exists. Inside the if block, you call createObjectStore() to create an object store named 'firstOS'.

How to define primary keys

When you define object stores, you can define how data is uniquely identified in the store using a primary key. You can define a primary key by either defining a key path, or by using a key generator.

A key path is a property that always exists and contains a unique value. For example, in the case of a people object store, you could choose the email address as the key path:

import {openDB} from 'idb';

async function createStoreInDB () {
  const dbPromise = await openDB('test-db2', 1, {
    upgrade (db) {
      if (!db.objectStoreNames.contains('people')) {
        db.createObjectStore('people', { keyPath: 'email' });
      }
    }
  });
}

createStoreInDB();

This example creates an object store called 'people', and assigns the email property as the primary key in the keyPath option.

You could also use a key generator, such as autoIncrement. The key generator creates a unique value for every object added to the object store. By default, if you don't specify a key, IndexedDB creates a key and stores it separately from the data.

import {openDB} from 'idb';

async function createStoreInDB () {
  const dbPromise = await openDB('test-db2', 1, {
    upgrade (db) {
      if (!db.objectStoreNames.contains('notes')) {
        db.createObjectStore('notes', { autoIncrement: true });
      }
    }
  });
}

createStoreInDB();

This example creates an object store called 'notes', and sets the primary key to be assigned automatically as an auto-incrementing number.

import {openDB} from 'idb';

async function createStoreInDB () {
  const dbPromise = await openDB('test-db2', 1, {
    upgrade (db) {
      if (!db.objectStoreNames.contains('logs')) {
        db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true });
      }
    }
  });
}

createStoreInDB();

This example is similar to the previous example, but this time the auto-incrementing value is explicitly assigned to a property named 'id'.

Choosing which method to use to define the key depends on your data. If your data has a property that is always unique, you can make it the keyPath to enforce this uniqueness. Otherwise, using an auto-incrementing value makes sense.

The following code creates three object stores demonstrating the various ways of defining primary keys in object stores:

import {openDB} from 'idb';

async function createStoresInDB () {
  const dbPromise = await openDB('test-db2', 1, {
    upgrade (db) {
      if (!db.objectStoreNames.contains('people')) {
        db.createObjectStore('people', { keyPath: 'email' });
      }

      if (!db.objectStoreNames.contains('notes')) {
        db.createObjectStore('notes', { autoIncrement: true });
      }

      if (!db.objectStoreNames.contains('logs')) {
        db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true });
      }
    }
  });
}

createStoresInDB();

How to define indexes

Indexes are a kind of object store used to retrieve data from the reference object store by a specified property. An index lives inside the reference object store and contains the same data, but uses the specified property as its key path instead of the reference store's primary key. Indexes must be made when you create your object stores and can also be used to define a unique constraint on your data.

To create an index, call the createIndex() method on an object store instance:

import {openDB} from 'idb';

async function createIndexInStore() {
  const dbPromise = await openDB('storeName', 1, {
    upgrade (db) {
      const objectStore = db.createObjectStore('storeName');

      objectStore.createIndex('indexName', 'property', options);
    }
  });
}

createIndexInStore();

This method creates and returns an index object. The createIndex() method on the object store's instance takes the name of the new index as the first argument, and the second argument refers to the property on the data you want to index. The final argument lets you define two options that determine how the index operates: unique and multiEntry. If unique is set to true, the index does not allow duplicate values for a single key. Next, multiEntry determines how createIndex() behaves when the indexed property is an array. If it's set to true, createIndex() adds an entry in the index for each array element. Otherwise, it adds a single entry containing the array.

Here's an example:

import {openDB} from 'idb';

async function createIndexesInStores () {
  const dbPromise = await openDB('test-db3', 1, {
    upgrade (db) {
      if (!db.objectStoreNames.contains('people')) {
        const peopleObjectStore = db.createObjectStore('people', { keyPath: 'email' });

        peopleObjectStore.createIndex('gender', 'gender', { unique: false });
        peopleObjectStore.createIndex('ssn', 'ssn', { unique: true });
      }

      if (!db.objectStoreNames.contains('notes')) {
        const notesObjectStore = db.createObjectStore('notes', { autoIncrement: true });

        notesObjectStore.createIndex('title', 'title', { unique: false });
      }

      if (!db.objectStoreNames.contains('logs')) {
        const logsObjectStore = db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true });
      }
    }
  });
}

createIndexesInStores();

In this example, the 'people' and 'notes' object stores have indexes. To create the indexes, you first assign the result of createObjectStore() (which is an object store object) to a variable so you can call createIndex() on it.

How to work with data

This section describes how to create, read, update, and delete data. These operations are all asynchronous, using promises where the IndexedDB API uses requests. This simplifies the API. Instead of listening for events triggered by the request, you can call .then() on the database object returned from the openDB() method to start interactions with the database, or await its creation.

All data operations in IndexedDB are carried out inside a transaction. Each operation has this form:

  1. Get database object.
  2. Open transaction on database.
  3. Open object store on transaction.
  4. Perform operation on object store.

A transaction can be thought of as a safe wrapper around an operation or group of operations. If one of the actions within a transaction fails, all of the actions are rolled back. Transactions are specific to one or more object stores, which you define when you open the transaction. They can be read-only or read and write. This signifies whether the operations inside the transaction read the data or make a change to the database.

Create data

To create data, call the add() method on the database instance and pass in the data you want to add. The add() method's first argument is the object store you want to add the data to, and the second argument is an object containing the fields and associated data you want to add. Here's the simplest example, where a single row of data is added:

import {openDB} from 'idb';

async function addItemToStore () {
  const db = await openDB('example-database', 1);

  await db.add('storeName', {
    field: 'data'
  });
}

addItemToStore();

Each add() call occurs within a transaction, so even if the promise resolves successfully, it doesn't necessarily mean the operation worked. Remember, if one of the actions in the transaction fails, all of the operations in the transaction are rolled back.

To be sure that the add operation was carried out, you need to check if the whole transaction has completed using the transaction.done() method. This is a promise that resolves when the transaction completes, and rejects if the transaction errors. Note that this method doesn't actually close the transaction. The transaction completes on its own. You must perform this check for all "write" operations, because it is your only way of knowing that the changes to the database have actually been carried out.

The following code shows use of the add() method, but using a transaction this time:

import {openDB} from 'idb';

async function addItemsToStore () {
  const db = await openDB('test-db4', 1, {
    upgrade (db) {
      if (!db.objectStoreNames.contains('foods')) {
        db.createObjectStore('foods', { keyPath: 'name' });
      }
    }
  });
  
  // Create a transaction on the 'foods' store in read/write mode:
  const tx = db.transaction('foods', 'readwrite');

  // Add multiple items to the 'foods' store in a single transaction:
  await Promise.all([
    tx.store.add({
      name: 'Sandwich',
      price: 4.99,
      description: 'A very tasty sandwich!',
      created: new Date().getTime(),
    }),
    tx.store.add({
      name: 'Eggs',
      price: 2.99,
      description: 'Some nice eggs you can cook up!',
      created: new Date().getTime(),
    }),
    tx.done
  ]);
}

addItemsToStore();

Once you open the database (and create an object store if needed), you'll need to open a transaction by calling the transaction() method on it. This method takes an argument for the store you want to transact on, as well as the mode. In this case, we're interested in writing to the store, so 'readwrite' is specified in the preceding example.

The next step is to begin adding items to the store as part of the transaction. In the preceding example, we're dealing with three operations on the 'foods' store that each return a promise:

  1. Adding a record for a tasty sandwich.
  2. Adding a record for some eggs.
  3. Signalling that the transaction is complete (tx.done).

Because all of these actions are all promise-based, we need to wait for all of them to finish. Passing these promises to Promise.all is a nice, ergonomic way to get this done. Promise.all accepts an array of promises, and will finish when all of the promises passed to it have resolved.

For the two records being added, the transaction instance's store interface has an add method that can be called, and the data is passed to each. The Promise.all call itself can be awaited, and will finish when the transaction completes.

Read data

To read data, call the get() method on the database instance you retrieve using the openDB() method. get() takes the name of the store, as well as primary key value of the object you want to retrieve from the store. Here is a basic example:

import {openDB} from 'idb';

async function getItemFromStore () {
  const db = await openDB('example-database', 1);

  // Get a value from the object store by its primary key value:
  const value = await db.get('storeName', 'unique-primary-key-value');
}

getItemFromStore();

As with add(), the get() method returns a promise, so you can await it if you prefer, or use the .then() callback all promises offer if you'd reather not.

The following example uses the get() method on the 'test-db4' database's 'foods' object store to get a single row by the 'name' primary key:

import {openDB} from 'idb';

async function getItemFromStore () {
  const db = await openDB('test-db4', 1);
  const value = await db.get('foods', 'Sandwich');

  console.dir(value);
}

getItemFromStore();

To retrieve a single row from the database is fairly straightforward: you open the database, and specify the object store and primary key value of the row you want to get data from. Because the get() method returns a promise, you can await it.

Update data

To update data, call the put() method on the object store. The put() method is similar to the add() method and can also be used in place of add() to create data in the object store. Here's the simplest example of using put() to update a row in an object store by its primary key value:

import {openDB} from 'idb';

async function updateItemInStore () {
  const db = await openDB('example-database', 1);

  // Update a value from in an object store with an in-line key:
  await db.put('storeName', { inlineKeyName: 'newValue' });

  // Update a value from in an object store with an out-of-line key.
  // In this case, the out-of-line key value is 1, which is the
  // auto-incremented value.
  await db.put('otherStoreName', { field: 'value' }, 1);
}

updateItemInStore();

Like other methods, this method returns a promise. You can also use put() as part of a transaction, much like you would with the add() method. Here's an example using the 'foods' store from earlier, except we update the price of sandwich and the eggs:

import {openDB} from 'idb';

async function updateItemsInStore () {
  const db = await openDB('test-db4', 1);
  
  // Create a transaction on the 'foods' store in read/write mode:
  const tx = db.transaction('foods', 'readwrite');

  // Update multiple items in the 'foods' store in a single transaction:
  await Promise.all([
    tx.store.put({
      name: 'Sandwich',
      price: 5.99,
      description: 'A MORE tasty sandwich!',
      updated: new Date().getTime() // This creates a new field
    }),
    tx.store.put({
      name: 'Eggs',
      price: 3.99,
      description: 'Some even NICER eggs you can cook up!',
      updated: new Date().getTime() // This creates a new field
    }),
    tx.done
  ]);
}

updateItemsInStore();

How items get updated depends on how you set a key. If you set a keyPath, each row in the object store is associated with what is known as an in-line key. The preceding example updates rows based on this key, and when you update rows in this situation, you'll need to specify that key in order for the appropriate item in the object store to actually be updated. An out-of-line key is created by setting an autoIncrement as the primary key.

Delete data

To delete data, call the delete() method on the object store:

import {openDB} from 'idb';

async function deleteItemFromStore () {
  const db = await openDB('example-database', 1);

  // Delete a value 
  await db.delete('storeName', 'primary-key-value');
}

deleteItemFromStore();

Like add() and put(), this can also be used as part of a transaction:

import {openDB} from 'idb';

async function deleteItemsFromStore () {
  const db = await openDB('test-db4', 1);
  
  // Create a transaction on the 'foods' store in read/write mode:
  const tx = db.transaction('foods', 'readwrite');

  // Delete multiple items from the 'foods' store in a single transaction:
  await Promise.all([
    tx.store.delete('Sandwich'),
    tx.store.delete('Eggs'),
    tx.done
  ]);
}

deleteItemsFromStore();

The structure of the database interaction is the same as for the other operations. Note that you again check that the whole transaction has completed by including the tx.done method in the array you pass to Promise.all to be sure that the deletion was carried out.

Getting all the data

So far you have only retrieved objects from the store one at a time. You can also retrieve all of the data (or a subset) from an object store or index using either the getAll() method or using cursors.

Using the getAll() method

The simplest way to retrieve all of an object store's data is to call the getAll() method on the object store or index, like this:

import {openDB} from 'idb';

async function getAllItemsFromStore () {
  const db = await openDB('test-db4', 1);

  // Get all values from the designated object store:
  const allValues = await db.getAll('storeName');

  console.dir(allValues);
}

getAllItemsFromStore();

This method returns all the objects in the object store, with no constraints whatsoever. It's the most direct way of getting all values from an object store, but also the least flexible.

import {openDB} from 'idb';

async function getAllItemsFromStore () {
  const db = await openDB('test-db4', 1);

  // Get all values from the designated object store:
  const allValues = await db.getAll('foods');

  console.dir(allValues);
}

getAllItemsFromStore();

Here, getAll() is called on the 'foods' object store. This returns all of the objects from the 'foods' the store ordered by the primary key.

How to use cursors

Another way to retrieve all of the data—one that gives you most flexibility than simply getting everything at once—is to use a cursor. A cursor selects each object in an object store or index one-by-one, letting you do something with the data as it is selected. Cursors, like the other database operations, work within transactions.

You create the cursor by calling the openCursor() method on the object store. This is done as part of a transaction. Using the 'foods' store from previous examples, this is how you would advance a cursor through all rows of data in an object store:

import {openDB} from 'idb';

async function getAllItemsFromStoreWithCursor () {
  const db = await openDB('test-db4', 1);
  const tx = await db.transaction('foods', 'readonly');

  // Open a cursor on the designated object store:
  let cursor = await tx.store.openCursor();

  // Iterate on the cursor, row by row:
  while (cursor) {
    // Show the data in the row at the current cursor position:
    console.log(cursor.key, cursor.value);

    // Advance the cursor to the next row:
    cursor = await cursor.continue();
  }
}

getAllItemsFromStoreWithCursor();

The transaction in this case is opened in 'readonly' mode, and its openCursor method is called. In a subsequent while loop, the row at the cursor's current position can have its key and value properties read, and you can operate on those values in whichever way makes the most sense for your application. Once you're ready, you can then call the cursor object's continue() method to go to the next row, and the while loop terminates once the end of the dataset has been reached.

How to use cursors with ranges and indexes

You can get all the data in a couple of different ways, but what if you want only a subset of the data based on a particular property? This is where indexes come in. Indexes let you fetch the data in an object store by a property other than the primary key. You can create an index on any property (which becomes the keyPath for the index), specify a range on that property, and get the data within the range using the getAll() method or a cursor.

You define the range using the IDBKeyRange object. This object has five methods that are used to define the limits of the range:

As expected, the upperBound() and lowerBound() methods specify the upper and lower limits of the range.

IDBKeyRange.lowerBound(indexKey);

Or:

IDBKeyRange.upperBound(indexKey);

They each take one argument which is the index's keyPath value of the item you want to specify as the upper or lower limit.

The bound() method is used to specify both an upper and lower limit, and takes the lower limit as the first argument:

IDBKeyRange.bound(lowerIndexKey, upperIndexKey);

The range for these functions is inclusive by default, but can be specified as exclusive by passing true as the second argument (or the third and fourth in the case of bound(), for the lower and upper limits respectively). An inclusive range includes the data at the limits of the range. An exclusive range does not.

Let's look at an example. For this demo, you have created an index on the 'price' property in the 'foods' object store. You have also added a small form with two inputs for the upper and lower limits of the range. Imagine you are passing in the lower and upper bounds to the function as floating point numbers representing prices:

import {openDB} from 'idb';

async function searchItems (lower, upper) {
  if (!lower === '' && upper === '') {
    return;
  }

  let range;

  if (lower !== '' && upper !== '') {
    range = IDBKeyRange.bound(lower, upper);
  } else if (lower === '') {
    range = IDBKeyRange.upperBound(upper);
  } else {
    range = IDBKeyRange.lowerBound(lower);
  }

  const db = await openDB('test-db4', 1);
  const tx = await db.transaction('foods', 'readonly');
  const index = tx.store.index('price');

  // Open a cursor on the designated object store:
  let cursor = await index.openCursor(range);

  if (!cursor) {
    return;
  }

  // Iterate on the cursor, row by row:
  while (cursor) {
    // Show the data in the row at the current cursor position:
    console.log(cursor.key, cursor.value);

    // Advance the cursor to the next row:
    cursor = await cursor.continue();
  }
}

// Get items priced between one and four dollars:
searchItems(1.00, 4.00);

The code first gets the values for the limits and checks if the limits exist. The next block of code decides which method to use to limit the range based on the values. In the database interaction, open the object store on the transaction as usual, then open the 'price' index on the object store. The 'price' index allows you to search for the items by price.

Then, open a cursor on the index and pass in the range. The cursor now returns a promise representing the first object in the range, or undefined if there is no data within the range. The cursor.continue() method returns a cursor representing the next object and so on through the loop until you reach the end of the range.

Using database versioning

When calling the openDB() method, you can specify the database version number in the second parameter. In all of the examples in this guide, the version has been set to 1, but a database can be upgraded to a new version if you need to modify it in some way. If the version specified is greater than the version of the existing database, the upgrade callback in the event object executes, allowing you to add new object stores and indexes to the database.

The db object in the upgrade callback has a special oldVersion property, which indicates the current version number of the database existing in the browser. You can pass this version number into a switch statement to execute blocks of code inside the upgrade callback based on the existing database version number. Here's an example:

import {openDB} from 'idb';

const db = await openDB('example-database', 2, {
  upgrade (db, oldVersion) {
    switch (oldVersion) {
      case 0:
        // Create first object store:
        db.createObjectStore('store', { keyPath: 'name' });

      case 1:
        // Get the original object store, and create an index on it:
        const tx = await db.transaction('store', 'readwrite');
        tx.store.createIndex('name', 'name');
    }
  }
});

This example sets the newest version of the database to 2. When this code first executes, and since the database doesn't yet exist in the browser, oldVersion is 0, and the switch statement starts at case 0. In the example, this results in a 'store' object store being added to the database.

To create a 'description' index on the 'store' object store, update the version number and add a new case block like so:

import {openDB} from 'idb';

const db = await openDB('example-database', 3, {
  upgrade (db, oldVersion) {
    switch (oldVersion) {
      case 0:
        // Create first object store:
        db.createObjectStore('store', { keyPath: 'name' });

      case 1:
        // Get the original object store, and create an index on it:
        const tx = await db.transaction('store', 'readwrite');
        tx.store.createIndex('name', 'name');

      case 2:
        const tx = await db.transaction('store', 'readwrite');
        tx.store.createIndex('description', 'description');
    }
  }
});

Assuming the database you created in the previous example still exists in the browser, when this executes oldVersion is 2. The case 0 and case 1 are skipped, and the browser executes the code in case 2, which creates a 'description' index. Once all this has finished, the browser has a database at version 3 containing a 'store' object store with 'name' and 'description' indexes.

Further reading

The following resources can provide a bit more information and context when it comes to using IndexedDB.

IndexedDB Documentation

Data storage limits