Frontend Engineering/
Lesson

If localStorage is a sticky note on your monitor, IndexedDBWhat is indexeddb?A database built into the browser that stores structured data, supporting large datasets and offline-first apps. is a filing cabinet with labeled folders, indexed tabs, and a locking mechanism so nothing gets corrupted. It is significantly more powerful and significantly more complex. Fortunately, the idb library handles the complexity, and you get a clean async APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. that feels like a proper database.

You will reach for IndexedDB when localStorage's ~5MB ceiling is not enough, when you need to query data by fields other than a single key, or when you are building something that should work offline.

Why not just use localStorage for everything?

localStorage is simple, but it has hard limits that bite you in real apps:

  • 5-10MB maximum, fine for preferences, not for product catalogs
  • Strings only, storing complex objects means serializing and deserializing every read
  • Synchronous, one large JSONWhat is json?A text format for exchanging data between systems. It uses key-value pairs and arrays, and every programming language can read and write it..parse call can visibly freeze the page
  • No querying, you can only look up by exact key, not by field value

IndexedDBWhat is indexeddb?A database built into the browser that stores structured data, supporting large datasets and offline-first apps. solves all of these. The trade-off is complexity, which is why idb exists.

02

Core concepts

Object stores

An object store is roughly equivalent to a database table. You define it during the database upgrade, give it a key path (the field that acts as primary key), and optionally enable auto-increment.

import { openDB } from 'idb';

const db = await openDB('my-app', 1, {
  upgrade(db) {
    // Create an object store for tasks
    const taskStore = db.createObjectStore('tasks', {
      keyPath: 'id',
      autoIncrement: true
    });

    // Add indexes for fast lookups by field
    taskStore.createIndex('byStatus', 'status');
    taskStore.createIndex('byDueDate', 'dueDate');
  }
});
The upgrade callback only runs when the version number increases. It is the only place you can create or modify object stores and indexes. Think of it like a database migration.

Basic CRUDWhat is crud?Create, Read, Update, Delete - the four basic operations almost every application performs on data.

Once you have a database reference, reading and writing are straightforward:

// Add a new record (id assigned automatically)
const id = await db.add('tasks', {
  title: 'Write tests',
  status: 'todo',
  dueDate: '2025-04-01'
});

// Read by primary key
const task = await db.get('tasks', id);

// Update (replaces the entire record)
await db.put('tasks', { ...task, status: 'done' });

// Delete
await db.delete('tasks', id);

// Get everything
const allTasks = await db.getAll('tasks');

Querying with indexes

Indexes are what make IndexedDBWhat is indexeddb?A database built into the browser that stores structured data, supporting large datasets and offline-first apps. genuinely useful. Without them you would have to load every record and filter in JavaScript. With them, the database does the filtering for you.

// Get all tasks with status 'todo'
const todos = await db.getAllFromIndex('tasks', 'byStatus', 'todo');

// Get tasks due on a specific date
const dueTomorrow = await db.getAllFromIndex('tasks', 'byDueDate', '2025-04-02');
03

Transactions

Every IndexedDBWhat is indexeddb?A database built into the browser that stores structured data, supporting large datasets and offline-first apps. operation runs inside a transactionWhat is transaction?A group of database operations that either all succeed together or all fail together, preventing partial updates.. With idb, transactions are often implicit (one operation = one auto-transaction), but for multiple related writes you should use an explicit transaction to keep data consistent.

// Explicit transaction: either both writes succeed or neither does
const tx = db.transaction(['tasks', 'projects'], 'readwrite');

await tx.objectStore('tasks').add({ title: 'New task', projectId: 42 });
await tx.objectStore('projects').put({ id: 42, taskCount: 5 });

await tx.done; // Commits the transaction
If any operation inside a transaction throws, the entire transaction is rolled back automatically. This is the same guarantee you get from SQL transactions.
04

Versioning and migrations

When you need to change the database schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required. (add a store, add an indexWhat is index?A data structure the database maintains alongside a table so it can find rows by specific columns quickly instead of scanning everything., rename a field), you increment the version number. The upgrade callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs. receives oldVersion and newVersion so you can apply incremental changes.

const db = await openDB('my-app', 3, {
  upgrade(db, oldVersion) {
    if (oldVersion < 1) {
      db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
    }
    if (oldVersion < 2) {
      db.createObjectStore('projects', { keyPath: 'id', autoIncrement: true });
    }
    if (oldVersion < 3) {
      // Add index to existing store in v3
      const tx = db.transaction('tasks');
      tx.objectStore('tasks').createIndex('byPriority', 'priority');
    }
  }
});
05

When to use IndexedDBWhat is indexeddb?A database built into the browser that stores structured data, supporting large datasets and offline-first apps.

IndexedDB is the right choice for: offline-first web apps (PWAs), large datasets like product catalogs or message histories, apps that need to store files or binaryWhat is binary?A ready-to-run file produced by the compiler. You can send it to any computer and it just works - no install needed. data, and any situation where you need to query by field rather than by exact key.

It is overkill for: user preferences, tokens, small flags, anything that comfortably fits in a few KB.

06

Quick reference

Operationidb method
Open / create databaseopenDB(name, version, { upgrade })
Add a recorddb.add(storeName, value)
Read by keydb.get(storeName, key)
Update / upsertdb.put(storeName, value)
Delete by keydb.delete(storeName, key)
Get all recordsdb.getAll(storeName)
Query by indexdb.getAllFromIndex(storeName, indexName, query)
Count recordsdb.count(storeName)
javascript
// Complete IndexedDB example using the idb library

import { openDB } from 'idb';

const DB_NAME = 'TaskManager';
const DB_VERSION = 1;

async function getDB() {
  return openDB(DB_NAME, DB_VERSION, {
    upgrade(db) {
      if (!db.objectStoreNames.contains('tasks')) {
        const taskStore = db.createObjectStore('tasks', {
          keyPath: 'id',
          autoIncrement: true
        });
        taskStore.createIndex('byStatus', 'status');
        taskStore.createIndex('byPriority', 'priority');
        taskStore.createIndex('byDueDate', 'dueDate');
        taskStore.createIndex('byProject', 'projectId');
      }

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

export const TaskDB = {
  async create(task) {
    const db = await getDB();
    const newTask = {
      ...task,
      createdAt: Date.now(),
      updatedAt: Date.now(),
      status: task.status || 'todo'
    };
    const id = await db.add('tasks', newTask);
    return { ...newTask, id };
  },

  async getById(id) {
    const db = await getDB();
    return db.get('tasks', id);
  },

  async update(id, updates) {
    const db = await getDB();
    const existing = await db.get('tasks', id);
    if (!existing) throw new Error('Task not found');
    const updated = { ...existing, ...updates, id, updatedAt: Date.now() };
    await db.put('tasks', updated);
    return updated;
  },

  async delete(id) {
    const db = await getDB();
    await db.delete('tasks', id);
  },

  async getByStatus(status) {
    const db = await getDB();
    return db.getAllFromIndex('tasks', 'byStatus', status);
  },

  async getAll() {
    const db = await getDB();
    return db.getAll('tasks');
  }
};