DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

IndexedDB: The Browser's Structured Database — CRUD, Indexes, and Transactions

IndexedDB is the browser's built-in database — it can store gigabytes of structured data, works offline, and supports transactions. It's more powerful than localStorage but more complex. This guide covers the essentials: creating a database, CRUD operations, indexes, and a modern wrapper pattern.

Why IndexedDB?

Feature localStorage sessionStorage IndexedDB
Capacity ~5MB ~5MB Hundreds of MB+
Data types Strings only Strings only Any structured-clone-able value
Async No (blocks UI) No Yes
Transactions No No Yes
Indexes/queries No No Yes
Works offline Yes No Yes

Use IndexedDB for: offline apps, large datasets, structured records, caching API responses with queries.

Opening a Database

function openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MyApp', 1); // name, version

    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      // Create object stores (like tables) here
      if (!db.objectStoreNames.contains('tasks')) {
        const store = db.createObjectStore('tasks', {
          keyPath: 'id',           // Primary key field
          autoIncrement: false,    // We'll provide IDs
        });

        // Create indexes for querying
        store.createIndex('by-status', 'status', { unique: false });
        store.createIndex('by-due-date', 'dueDate', { unique: false });
        store.createIndex('by-user', 'userId', { unique: false });
      }
    };

    request.onsuccess = (event) => resolve(event.target.result);
    request.onerror = (event) => reject(event.target.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

onupgradeneeded runs when the database is created or when you increment the version number. This is the only place you can create/modify object stores.

CRUD Operations

Add a Record

async function addTask(db, task) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction('tasks', 'readwrite');
    const store = transaction.objectStore('tasks');

    const request = store.add(task); // Fails if key exists
    // Or: store.put(task);          // Upsert (add or update)

    request.onsuccess = () => resolve(request.result); // Returns the key
    request.onerror = () => reject(request.error);
  });
}

// Usage
const db = await openDatabase();
await addTask(db, {
  id: 'task-001',
  title: 'Write documentation',
  status: 'pending',
  userId: 'user-1',
  dueDate: '2026-10-30',
  createdAt: Date.now(),
});
Enter fullscreen mode Exit fullscreen mode

Get by Key

async function getTask(db, id) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction('tasks', 'readonly');
    const store = transaction.objectStore('tasks');
    const request = store.get(id);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Update

async function updateTask(db, id, updates) {
  return new Promise(async (resolve, reject) => {
    const transaction = db.transaction('tasks', 'readwrite');
    const store = transaction.objectStore('tasks');

    // Get existing, then put updated
    const getRequest = store.get(id);
    getRequest.onsuccess = () => {
      const task = { ...getRequest.result, ...updates, updatedAt: Date.now() };
      const putRequest = store.put(task);
      putRequest.onsuccess = () => resolve(task);
      putRequest.onerror = () => reject(putRequest.error);
    };
    getRequest.onerror = () => reject(getRequest.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Delete

async function deleteTask(db, id) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction('tasks', 'readwrite');
    const store = transaction.objectStore('tasks');
    const request = store.delete(id);

    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Querying with Indexes

Without indexes, you can only get records by their primary key. Indexes let you query by any field:

// Get all tasks with status='pending'
async function getTasksByStatus(db, status) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction('tasks', 'readonly');
    const store = transaction.objectStore('tasks');
    const index = store.index('by-status');

    // getAll on index
    const request = index.getAll(status); // All matching records

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Get tasks in a date range using cursor
async function getTasksDue(db, startDate, endDate) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction('tasks', 'readonly');
    const index = transaction.objectStore('tasks').index('by-due-date');

    const range = IDBKeyRange.bound(startDate, endDate); // 'startDate' <= key <= 'endDate'
    const results = [];

    const request = index.openCursor(range);
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results); // No more records
      }
    };
    request.onerror = () => reject(request.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

IDBKeyRange Options

IDBKeyRange.only('pending')           // Exact match
IDBKeyRange.lowerBound('2026-01-01')  // >= date
IDBKeyRange.upperBound('2026-12-31')  // <= date
IDBKeyRange.bound('2026-01-01', '2026-12-31') // between (inclusive)
IDBKeyRange.bound('2026-01-01', '2026-12-31', true, true) // exclusive bounds
Enter fullscreen mode Exit fullscreen mode

Modern Wrapper: idb Library

The raw IndexedDB API is verbose. The idb library (from Jake Archibald) wraps it with Promises:

import { openDB } from 'idb';

const db = await openDB('MyApp', 1, {
  upgrade(db) {
    const store = db.createObjectStore('tasks', { keyPath: 'id' });
    store.createIndex('by-status', 'status');
  },
});

// CRUD without callbacks
await db.add('tasks', { id: 'task-1', title: 'Test', status: 'pending' });
const task = await db.get('tasks', 'task-1');
await db.put('tasks', { ...task, status: 'done' });
await db.delete('tasks', 'task-1');

// Index queries
const pending = await db.getAllFromIndex('tasks', 'by-status', 'pending');
Enter fullscreen mode Exit fullscreen mode

Much cleaner. idb is ~1KB gzipped.

Firefox DevTools: Storage Inspector

View and edit IndexedDB data:

  1. Open DevTools (F12)
  2. Go to Storage tab
  3. Expand IndexedDB in the left panel
  4. Navigate: database → object store → records

You can add, edit, and delete records directly from the Storage inspector — great for debugging.

Transactions and Error Handling

IndexedDB transactions auto-commit when all requests complete:

async function transferPoints(db, fromId, toId, amount) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');

    // Both operations in the same transaction — atomic
    const getFrom = store.get(fromId);
    getFrom.onsuccess = () => {
      const from = getFrom.result;
      if (from.points < amount) {
        tx.abort(); // Roll back the transaction
        reject(new Error('Insufficient points'));
        return;
      }

      store.put({ ...from, points: from.points - amount });

      const getTo = store.get(toId);
      getTo.onsuccess = () => {
        const to = getTo.result;
        store.put({ ...to, points: to.points + amount });
      };
    };

    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(new Error('Transaction aborted'));
  });
}
Enter fullscreen mode Exit fullscreen mode

IndexedDB is the right choice for any browser app that needs real structured data persistence.

Top comments (0)