Skip to content

Going Beyond CRUD

Basic IndexedDB CRUD — put, get, delete — gets you a long way, but real applications need more. This module covers the advanced layer that sits on top of the core API.

LessonWhat you will learn
1. This overviewHow indexes, cursors, key ranges, the idb library, migrations, and blobs fit together
2. IndexesCreating and querying secondary indexes; index.get vs index.getAll
3. Cursors & key rangesWalking ordered results; IDBKeyRange.bound, lowerBound, upperBound
4. The idb libraryPromise-based wrapper; why you should almost always use it in production
5. Migrationsonupgradeneeded patterns for safe schema evolution across versions
6. Blobs & filesStoring binary data — images, audio, PDFs — directly in an object store

An object store is like a table keyed on a primary key (usually id). An index is a secondary sorted view of the same records keyed on a different property — for example email or createdAt.

Without an index, finding all records where email === '[email protected]' requires opening a cursor over every record (a full scan). With an index on email, the engine jumps straight to the matching entries in O(log n).

// Creating a store with an index during onupgradeneeded
const store = db.createObjectStore('users', { keyPath: 'id' });
store.createIndex('by_email', 'email', { unique: true });
store.createIndex('by_age', 'age', { unique: false });

Once the index exists you query it inside any transaction:

const tx = db.transaction('users', 'readonly');
const index = tx.objectStore('users').index('by_email');
const user = await index.get('[email protected]');

A cursor iterates records in index order. You open it on either the object store (primary key order) or an index (secondary key order), then call cursor.continue() to advance:

const tx = db.transaction('users', 'readonly');
const index = tx.objectStore('users').index('by_age');
const range = IDBKeyRange.bound(18, 65); // ages 18–65 inclusive
let cursor = await index.openCursor(range);
while (cursor) {
console.log(cursor.value.name, cursor.value.age);
cursor = await cursor.continue();
}

Key ranges — bound, lowerBound, upperBound, only — can be passed to both openCursor and getAll to filter without a full scan.


The raw IndexedDB API is callback/event-driven and verbose. Jake Archibald’s idb library wraps it in clean Promises with no runtime overhead:

import { openDB } from 'idb';
const db = await openDB('my-app', 1, {
upgrade(db) {
const store = db.createObjectStore('users', { keyPath: 'id' });
store.createIndex('by_email', 'email', { unique: true });
},
});
await db.put('users', { id: 1, name: 'Ada', email: '[email protected]', age: 36 });
const user = await db.getFromIndex('users', 'by_email', '[email protected]');

The module’s later lessons use both raw IDB (to understand the primitives) and idb (for realistic production patterns).


Every time you increment the version number passed to indexedDB.open, the browser fires onupgradeneeded before the database opens. This is the only place where you can create or delete object stores and indexes.

const db = await openDB('app', 3, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore('users', { keyPath: 'id' });
}
if (oldVersion < 2) {
db.transaction.objectStore('users')
.createIndex('by_email', 'email', { unique: true });
}
if (oldVersion < 3) {
db.createObjectStore('files', { keyPath: 'name' });
}
},
});

Chained if (oldVersion < N) blocks let users upgrade from any older version in a single pass — the key migration pattern covered in depth in lesson 5.


IndexedDB stores structured-cloneable values, which includes Blob, File, ArrayBuffer, and ImageData. There is no need to base64-encode binary data the way you would for localStorage:

const response = await fetch('/avatar.png');
const blob = await response.blob();
await db.put('files', { name: 'avatar.png', data: blob, size: blob.size });
const record = await db.get('files', 'avatar.png');
const url = URL.createObjectURL(record.data);
// use url in an <img> src

Storing blobs is covered end-to-end in lesson 6.


flowchart LR
    subgraph ObjectStore["Object Store — users"]
        direction TB
        R1["{ id:1, name:'Ada',   email:'[email protected]',   age:36 }"]
        R2["{ id:2, name:'Bob',   email:'[email protected]',   age:24 }"]
        R3["{ id:3, name:'Carol', email:'[email protected]', age:31 }"]
    end

    subgraph PK["Primary Key (id)"]
        direction TB
        PK1["1 → record 1"]
        PK2["2 → record 2"]
        PK3["3 → record 3"]
    end

    subgraph IDX["Index — by_email"]
        direction TB
        I1["[email protected]   → record 1"]
        I2["[email protected]   → record 2"]
        I3["[email protected] → record 3"]
    end

    ObjectStore --> PK
    ObjectStore --> IDX
    IDX -. "index.get('[email protected]')" .-> R1
An object store with a secondary index on the email property

Runnable demo: open a DB, create an index, query by index

Section titled “Runnable demo: open a DB, create an index, query by index”
Browser Storage
Where is the only place you can create a new object store or index in IndexedDB?
What is the main advantage of a secondary index over a full cursor scan?
Which IDBKeyRange method returns a range that includes both the lower and upper bound values?
Which of the following can IndexedDB store natively without base64-encoding?