The Ultimate Interactive Guide to IndexedDB

Learn by doing with a live recipe book example.

Core Concepts

Before we dive into the code, let's understand the fundamental principles of IndexedDB:

  • Asynchronous API: Nearly all IndexedDB operations are asynchronous. You request an operation, and the result is delivered to an event handler. This non-blocking nature is essential for web performance.
  • Transactional Database: All data operations happen within a transaction. This ensures data integrity. If one step fails, the entire transaction is rolled back.
  • Object-Oriented: IndexedDB is a NoSQL, object-oriented database. Instead of tables with rows and columns, you have "object stores" that hold key-value pairs.
  • Same-Origin Policy: IndexedDB is tied to a specific origin (protocol, domain, and port). Each origin has its own set of databases.

1. Opening a Database

The first step is to open a connection. The `indexedDB.open()` method takes a database name and a version number. The version is crucial; incrementing it triggers the `onupgradeneeded` event, which is the only place you can alter the database's structure (like creating object stores or indexes).


const request = indexedDB.open('RecipeBookDB', 1);

request.onerror = (event) => {
  console.error(`Database error: ${event.target.errorCode}`);
};

request.onsuccess = (event) => {
  db = event.target.result;
  // Database is open, now we can query it
};

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const objectStore = db.createObjectStore('recipes', { keyPath: 'id', autoIncrement: true });
  objectStore.createIndex('name', 'name', { unique: false });
};

Live Recipe Database Example

Add a New Recipe

Saved Recipes

No recipes saved yet. Add one above!

2. CRUD Operations

All data operations must be performed within a transaction. You create a transaction, get a reference to your object store, and then perform the desired operation.

Adding & Updating Data

The `add()` method adds a new object, while `put()` will either add a new object or update an existing one if the key already exists. This is why our form can handle both creating and editing.


function addOrUpdateRecipe(event) {
    // ... (get form data)
    const transaction = db.transaction(['recipes'], 'readwrite');
    const objectStore = transaction.objectStore('recipes');
    
    // If we have an ID, it's an update (put), 
    // otherwise it's a new item (add).
    const request = id ? objectStore.put(recipe) : objectStore.add(recipe);

    request.onsuccess = () => {
        displayRecipes(); // Refresh the list
    };
}

Reading & Deleting Data

To read all data, `getAll()` is the most efficient method. For deleting, you simply call `delete()` with the key of the item you want to remove.


function displayRecipes() {
    const objectStore = db.transaction('recipes').objectStore('recipes');
    const request = objectStore.getAll();
    // ... (display logic in onsuccess)
}

function deleteRecipe(id) {
    const request = db.transaction(['recipes'], 'readwrite')
                      .objectStore('recipes')
                      .delete(id);
    request.onsuccess = () => {
        displayRecipes(); // Refresh the list
    };
}

3. Storage Limits & Persistence

IndexedDB offers a large amount of storage, but it's not infinite. The exact limit depends on the browser and the user's device storage. Generally, browsers allocate a quota based on a percentage of the total disk space (e.g., Chrome might allow up to 60% of disk space for its entire profile).

However, this storage is considered "temporary" by default. If the user's device runs low on space, the browser may automatically clear your database without warning! To prevent this, you can request persistent storage.


async function requestPersistence() {
  if (navigator.storage && navigator.storage.persist) {
    const isPersisted = await navigator.storage.persisted();
    console.log(`Persisted storage granted: ${isPersisted}`);
    if (!isPersisted) {
      const granted = await navigator.storage.persist();
      console.log(`Newly granted persisted storage: ${granted}`);
    }
  }
}

// Call this function early in your app's lifecycle
requestPersistence();

If the user grants permission, the browser will protect your data from automatic eviction, making it much more reliable for offline applications.

4. Simplifying with the `idb` Library

The native IndexedDB API is powerful but very verbose due to its reliance on event handlers and callbacks. The `idb` library by Jake Archibald is a tiny, promise-based wrapper that makes the API much more ergonomic and compatible with modern `async/await` syntax.

Instead of nested callbacks, you can write clean, linear code. Compare getting an item natively versus with `idb`:


// With the idb library (using async/await)
import { openDB } from 'idb';

async function getRecipe(id) {
  const db = await openDB('RecipeBookDB', 1);
  const recipe = await db.get('recipes', id);
  return recipe;
}

// The native way (with callbacks)
function getRecipeNative(id, callback) {
  const request = indexedDB.open('RecipeBookDB', 1);
  request.onsuccess = (event) => {
    const db = event.target.result;
    const transaction = db.transaction('recipes', 'readonly');
    const store = transaction.objectStore('recipes');
    const getRequest = store.get(id);
    getRequest.onsuccess = () => {
      callback(getRequest.result);
    };
  };
}

Using a library like `idb` is highly recommended for any non-trivial application to improve code readability and maintainability.

5. Comparison with WASM Databases (SQLite & pg-lite)

While IndexedDB is the native browser database, WebAssembly (WASM) has enabled powerful, server-grade databases like SQLite and PostgreSQL to run directly in the browser. Here’s how they compare:

Feature IndexedDB / `idb` SQLite (WASM) pg-lite (PostgreSQL WASM)
Database Model NoSQL (Key-Value / Object) Relational (SQL) Relational (SQL)
Query Language JavaScript API SQL SQL (PostgreSQL dialect)
Bundle Size None (Native API) ~400-500 KB ~1-2 MB+
Best For Offline-first apps, caching JSON, simple to medium complexity data. Complex relational queries (JOINs), porting existing SQL apps, in-browser analytics. Porting complex server apps that rely on specific PostgreSQL features.

When to Choose Which?

  • Use IndexedDB / `idb` when you need a lightweight, native solution for storing JavaScript objects. It's perfect for progressive web apps (PWAs) and caching server data for offline use.
  • Use SQLite (WASM) when your application requires complex relational data modeling, you need to perform `JOIN`s across tables, or you are porting an existing application that already uses SQLite. It brings the power of a true SQL database to the client.
  • Use `pg-lite` for niche cases where you need to run an application that specifically depends on PostgreSQL features (like extensions or advanced data types) in the browser. Its larger size makes it less suitable for general-purpose client-side storage.

Mitigating Bundle Size with Service Workers

Yes, you can absolutely offset the large initial download size of WASM databases by caching them with a service worker. This is a common and highly effective strategy.

The process works like this: on the user's first visit, the service worker fetches the large WASM file from the network and stores it in the Cache API. On all subsequent visits, the service worker intercepts the request and serves the file directly from the cache, skipping the network entirely. This makes the application load almost instantly after the first time. While this doesn't eliminate the initial download cost, it makes the experience for returning users significantly better and is a key technique for building high-performance, offline-capable applications with these powerful tools.