loading

Check for support

IsSupported

IndexedDB is available in every modern browser, but private-browsing modes and unusual embedding contexts can restrict it - feature-detect when your app depends on it.

C#
@inject Bit.Butil.IndexedDb indexedDb

if (await indexedDb.IsSupported())
{
    // safe to open databases
}
Live sample
support output
Results will appear here when you interact with the samples.

Open a database

Open

Open connects to (and creates if needed) the named database, returning a live IndexedDbHandle. The schema is applied inside the upgrade transaction and is idempotent - missing stores and indexes are created, an index whose definition changed is re-created, and a store marked Drop is removed. Pass version: null to attach to whatever version is already on disk instead of naming one. The handle reports what the open did: OldVersion, NewVersion and WasUpgraded.

C#
private IndexedDbHandle db = default!;

db = await indexedDb.Open("butil-docs-notes", version: 2, stores:
[
    new IndexedDbStoreSchema
    {
        Name = "notes",
        KeyPath = "id",
        Indexes =
        [
            new IndexedDbIndexSchema { Name = "by-category", KeyPath = "category" },
        ],
    },
    new IndexedDbStoreSchema { Name = "blobs" },   // out-of-line keys
],
onVersionChange: () => Console.WriteLine("another tab is upgrading; this handle was closed"),
onClose: () => Console.WriteLine("connection died - storage evicted?"));

if (db.WasUpgraded)
{
    // Runs after the upgrade transaction commits, so keep any backfill idempotent.
    Console.WriteLine($"migrated v{db.OldVersion} -> v{db.NewVersion}");
}
Live sample
open output
Results will appear here when you interact with the samples.

Write records

Add / Put

Add inserts a new record and throws when the key already exists; Put upserts - insert or overwrite. Records are plain C# objects, JSON-serialized on the way in. Because the store's keypath is id, the key comes from the record itself. Both return the record's key, which is the only way to read back what an autoIncrement store generated. A write resolves once its transaction commits, not merely once the request succeeds, so a completed call means the data is durable.

Razor
@code {
    // The handle from "Open a database" above; every call below goes through it.
    private IndexedDbHandle db = default!;

    // The record type. Property names are camel-cased on the way across, so "id" is the KeyPath the
    // store was created with.
    public class Note
    {
        public int Id { get; set; }
        public string Text { get; set; } = "";
        public string Category { get; set; } = "general";
    }

    private async Task Write()
    {
        var key = await db.Add("notes", new Note { Id = 1, Text = "Hello IndexedDB", Category = "intro" });

        await db.Put("notes", new Note { Id = 1, Text = "Hello again", Category = "intro" }); // upsert
    }
}
Live sample
Id
Text
Category
write output
Results will appear here when you interact with the samples.

Read records

Get / GetAll / GetAllKeys / Count

Get reads a single record by key, GetAll returns the whole store (optionally capped by a count), GetAllKeys lists just the keys, and Count reports how many records the store holds. GetRaw is the untyped sibling of Get, returning a JsonElement when no static type fits.

Razor
@code {
    private IndexedDbHandle db = default!;   // from "Open a database"

    private async Task Read()
    {
        var one = await db.Get<Note>("notes", 1);         // null when absent

        var all = await db.GetAll<Note>("notes");          // Note[]

        var keys = await db.GetAllKeys("notes");           // JsonElement[]

        var count = await db.Count("notes");               // int
    }
}
Live sample
Id to read
read output
Results will appear here when you interact with the samples.

Query a range of keys

IndexedDbKeyRange

A key range turns an exact-key lookup into an interval query - the main reason to reach for IndexedDB over simpler storage. Ranges go anywhere a key does: reads, counts, deletes and cursor walks all take one, so a single range object drives the whole operation set.

Razor
@code {
    private IndexedDbHandle db = default!;   // from "Open a database"

    private async Task Query()
    {
        // IndexedDbKeyRange.Bound(10, 20);                   // 10 <= key <= 20
        // IndexedDbKeyRange.Bound(10, 20, lowerOpen: true);  // 10 <  key <= 20
        // IndexedDbKeyRange.LowerBound(10);                  // key >= 10
        // IndexedDbKeyRange.UpperBound(20, open: true);      // key <  20
        // IndexedDbKeyRange.Only(15);                        // key == 15

        var range = IndexedDbKeyRange.Bound(10, 20);

        var notes = await db.GetAll<Note>("notes", range);
        var keys = await db.GetAllKeys("notes", range);
        var howMany = await db.Count("notes", range);

        await db.Delete("notes", range);                   // removes the whole interval
    }
}
Live sample
From id
To id
range output
Results will appear here when you interact with the samples.

Paginate with a cursor

GetPage / GetKeyPage

GetPage walks the store with a real cursor, which is how you paginate, read newest-first, or skip - none of which GetAll can express. Each record carries the keys the cursor saw alongside the value. The walk runs entirely on the JS side and hands back a materialized page: an IndexedDB transaction goes inactive as soon as control returns to the event loop, and every interop call does exactly that, so a cursor cannot be stepped one record at a time from .NET.

Razor
@code {
    private IndexedDbHandle db = default!;   // from "Open a database"

    private async Task Paginate()
    {
        var page = await db.GetPage<Note>("notes", skip: 20, take: 10);

        foreach (var record in page)
        {
            Console.WriteLine($"{record.PrimaryKey}: {record.Value!.Text}");
        }

        // newest first
        var latest = await db.GetPage<Note>("notes", direction: IndexedDbCursorDirection.Previous, take: 5);

        // keys only, restricted to a range
        var keys = await db.GetKeyPage("notes", IndexedDbKeyRange.LowerBound(10), take: 3);
    }
}
Live sample
Skip
Take
Direction
cursor output
Results will appear here when you interact with the samples.

Query by index

GetByIndex / GetAllByIndex / CountByIndex / DeleteByIndex / GetKeyPageByIndex

Indexes let you look records up by a property other than the primary key. The demo database declares a by-category index over the category property. Everything the store supports works on an index too - exact keys, ranges, counts and cursor walks - plus DeleteByIndex, which an index has no native equivalent for: it walks a cursor and deletes each match by primary key inside one transaction, so it removes all of them or none. Walking with NextUnique collapses duplicates, which is how you enumerate the distinct values an index holds.

Razor
@code {
    // The index has to exist in the schema the database was opened with - "by-category" is the one
    // declared on the notes store above.
    private IndexedDbHandle db = default!;

    private async Task ByIndex()
    {
        var first = await db.GetByIndex<Note>("notes", "by-category", "intro");

        var matches = await db.GetAllByIndex<Note>("notes", "by-category", "intro");

        var keys = await db.GetAllKeysByIndex("notes", "by-category", "intro");

        var howMany = await db.CountByIndex("notes", "by-category", "intro");

        var removed = await db.DeleteByIndex("notes", "by-category", "intro");   // returns the count

        // every distinct category, once each
        var distinct = await db.GetKeyPageByIndex("notes", "by-category",
            direction: IndexedDbCursorDirection.NextUnique);
    }
}
Live sample
Category
query output
Results will appear here when you interact with the samples.

Batch writes atomically

Transact

Transact runs every operation in one transaction spanning all the stores they touch, so a failure anywhere rolls the whole batch back. Operations are submitted together rather than issued one at a time - the same event-loop constraint that stops cursors being stepped from .NET stops a transaction being held open across calls, so passing them as a batch is what makes the atomicity real. It also means one transaction instead of one per write, which is markedly faster in bulk. The returned array holds each operation's resulting key, in order.

Razor
@code {
    private IndexedDbHandle db = default!;   // from "Open a database"

    private async Task Batch()
    {
        var keys = await db.Transact(
        [
            IndexedDbOperation.Put("notes", new Note { Id = 10, Text = "first", Category = "batch" }),
            IndexedDbOperation.Put("notes", new Note { Id = 11, Text = "second", Category = "batch" }),
            IndexedDbOperation.Delete("notes", 1),
        ],
        durability: IndexedDbDurability.Relaxed);

        // An Add on a key that already exists aborts the batch - none of the writes above it survive.
    }
}
Live sample
transaction output
Results will appear here when you interact with the samples.

Store binary data

PutBytes / GetBytes

Values normally cross the interop boundary as JSON, which cannot carry raw bytes - base64 inflates them by a third and large payloads hit string-length limits. PutBytes keeps the payload binary the whole way and stores it as an ArrayBuffer. Read it back with GetBytes, not Get: the stored record is not JSON, so Get cannot deserialize it. The demo writes into the blobs store, which has no keypath, so the key is passed explicitly.

Razor
@code {
    // "blobs" is the out-of-line store from the schema above, so the key is passed rather than read
    // off the value.
    private IndexedDbHandle db = default!;

    private async Task Binary(byte[] png)
    {
        await db.PutBytes("blobs", png, key: "logo");

        var bytes = await db.GetBytes("blobs", "logo");   // byte[]? - null when absent or not binary
    }
}
Live sample
Text to store as bytes
binary output
Results will appear here when you interact with the samples.

Inspect the schema

GetInfo / GetStoreInfo / GetIndexInfo / Databases / Compare

Read back what the database actually looks like rather than assuming the schema you asked for is the schema you got - useful after an upgrade, or when another part of the app owns the versioning. Databases lists this origin's databases (an empty result also means the browser cannot enumerate them - Firefox only shipped that in 126). Compare orders two keys exactly as IndexedDB does, which is the only reliable way to reason about key ordering without guessing.

Razor
@inject Bit.Butil.IndexedDb indexedDb

@code {
    private IndexedDbHandle db = default!;   // from "Open a database"

    private async Task Inspect()
    {
        var info = await db.GetInfo();          // name, version, store names
        var store = await db.GetStoreInfo("notes");   // keypath, autoIncrement, index names
        var index = await db.GetIndexInfo("notes", "by-category");   // keypath, unique, multiEntry

        // These two are on the service rather than a connection: they answer about the origin.
        var all = await indexedDb.Databases();  // IndexedDbDatabaseInfo[]

        var order = await indexedDb.Compare(1, 2);   // negative: 1 sorts before 2
    }
}
Live sample
schema output
Results will appear here when you interact with the samples.

Delete and clear

Delete / Clear

Delete removes a single record by key; Clear empties the store while keeping its schema and indexes intact.

Razor
@code {
    private IndexedDbHandle db = default!;   // from "Open a database"

    private async Task Remove()
    {
        await db.Delete("notes", 1);

        await db.Clear("notes");
    }
}
Live sample
Id to delete
delete output
Results will appear here when you interact with the samples.

Close and delete the database

DisposeAsync / DeleteDatabase

The handle owns the underlying IDBDatabase connection - dispose it when you are done so the connection closes. DeleteDatabase removes the database entirely; close any open handle first, otherwise the browser blocks the deletion until the connection goes away.

Razor
@implements IAsyncDisposable
@inject Bit.Butil.IndexedDb indexedDb

@code {
    private IndexedDbHandle? db;

    // A deleteDatabase request blocks for as long as any connection is still open - including this
    // page's own - so the handle is closed first.
    private async Task Drop()
    {
        if (db is not null) await db.DisposeAsync();
        db = null;

        await indexedDb.DeleteDatabase("butil-docs-notes");
    }

    public async ValueTask DisposeAsync()
    {
        if (db is not null) await db.DisposeAsync();
    }
}
Live sample
close output
Results will appear here when you interact with the samples.
Warning:
Versioning semantics Schema changes only happen inside an upgrade transaction, which the browser runs when Open is called with a version number higher than the stored one. Opening with the same version never touches the schema - to add a store or index to an existing database, bump the version. Opening with a lower version than the stored one fails outright; pass version: null to attach to whatever is on disk. Data migration is yours to do: check WasUpgraded and OldVersion after the open and keep the backfill idempotent, since it necessarily runs after the upgrade transaction has already committed.
Note:
Why cursors and transactions look different here An IndexedDB transaction goes inactive the moment control returns to the event loop, and every call into JavaScript does exactly that. So a cursor cannot be stepped a record at a time from .NET, and a transaction cannot be held open across calls. Both are instead expressed as a single call that carries the whole job: GetPage walks the cursor and returns a page, Transact submits the batch. Each still runs inside one real IDB transaction on the JS side, so the ordering and atomicity guarantees are the genuine ones.
Note:
What keys can be Keys travel as JSON, so numbers, strings, booleans and arrays of those (arrays being how compound keys are written) all work. A DateTime arrives in JavaScript as an ISO-8601 string rather than a Date - which still sorts correctly, as long as every key in the store was written the same way. For binary payloads use PutBytes/GetBytes, which bypass JSON entirely.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes indexedDB.
Open
ValueTask<IndexedDbHandle> Open(string name, int? version = 1, IndexedDbStoreSchema[]? stores = null, Action? onVersionChange = null, Action? onClose = null, Action? onBlocked = null)
Opens (and upgrades if needed) the named database, reconciling the schema during the upgrade transaction. Pass version: null to open whatever version is on disk. Returns a handle that owns the connection.
DeleteDatabase
ValueTask DeleteDatabase(string name)
Deletes the named database; resolves once the deletion completes.
Databases
ValueTask<IndexedDbDatabaseInfo[]> Databases()
Lists this origin's databases. Empty where the browser cannot enumerate them, so an empty result is not proof there are none.
Compare
ValueTask<int> Compare(object first, object second)
Orders two keys the way IndexedDB does: negative, zero or positive.
Handle.Id
Guid Id { get; }
Internal handle id - the database is keyed by this on the JS side.
Handle.DatabaseName / Version / StoreNames
string DatabaseName { get; } int Version { get; } string[] StoreNames { get; }
What was actually opened, captured at open time.
Handle.OldVersion / NewVersion / WasUpgraded
int OldVersion { get; } int NewVersion { get; } bool WasUpgraded { get; }
What the open did to the schema. Branch on WasUpgraded to run a data backfill.
Handle.Put<T>
ValueTask<JsonElement> Put<T>(string store, T value, object? key = null)
Inserts or updates a value, returning its key. Pass key for stores without a keypath. Resolves once the transaction commits.
Handle.Add<T>
ValueTask<JsonElement> Add<T>(string store, T value, object? key = null)
Inserts a new value, returning its key; throws on duplicate key.
Handle.PutBytes
ValueTask<JsonElement> PutBytes(string store, byte[] data, object? key = null)
Stores raw bytes as an ArrayBuffer, bypassing JSON. Read back with GetBytes.
Handle.GetBytes
ValueTask<byte[]?> GetBytes(string store, object query)
Reads a record written by PutBytes. Null when absent or the stored value is not binary.
Handle.Get<T>
ValueTask<T?> Get<T>(string store, object query)
Reads a value by key, or the first value in a key range; default when nothing matches.
Handle.GetRaw
ValueTask<JsonElement> GetRaw(string store, object query)
Reads a value as a JsonElement - no static type required.
Handle.GetKey
ValueTask<JsonElement> GetKey(string store, object query)
Reads the key of the first matching record without fetching its value.
Handle.GetAll<T>
ValueTask<T[]> GetAll<T>(string store, int? count = null) | GetAll<T>(string store, IndexedDbKeyRange range, int? count = null)
Reads all values in a store, or every value whose key falls in a range.
Handle.GetAllKeys
ValueTask<JsonElement[]> GetAllKeys(string store, int? count = null) | GetAllKeys(string store, IndexedDbKeyRange range, int? count = null)
Lists every key in a store, or the keys falling in a range.
Handle.Delete
ValueTask Delete(string store, object query)
Deletes the record(s) matching a key or a key range.
Handle.Clear
ValueTask Clear(string store)
Empties the store, keeping its schema and indexes.
Handle.Count
ValueTask<int> Count(string store, object? query = null)
Counts records in a store, or just those matching a key or range.
Handle.GetByIndex<T>
ValueTask<T?> GetByIndex<T>(string store, string index, object query)
Reads the first record matching an index query.
Handle.GetKeyByIndex
ValueTask<JsonElement> GetKeyByIndex(string store, string index, object query)
Reads the primary key of the first record matching an index query.
Handle.GetAllByIndex<T>
ValueTask<T[]> GetAllByIndex<T>(string store, string index, object query, int? count = null)
Reads every record matching an index query.
Handle.GetAllKeysByIndex
ValueTask<JsonElement[]> GetAllKeysByIndex(string store, string index, object query, int? count = null)
Lists the primary keys of every record matching an index query.
Handle.CountByIndex
ValueTask<int> CountByIndex(string store, string index, object? query = null)
Counts the records matching an index query.
Handle.DeleteByIndex
ValueTask<int> DeleteByIndex(string store, string index, object query)
Deletes every record matching an index query, returning how many. Atomic - all matches or none.
Handle.GetPage<T>
ValueTask<IndexedDbRecord<T>[]> GetPage<T>(string store, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)
Walks the store with a cursor and returns one page: values plus the keys the cursor saw. Paging, reverse order and skipping all live here.
Handle.GetKeyPage
ValueTask<IndexedDbKeyRecord[]> GetKeyPage(string store, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)
Same walk without deserializing the values.
Handle.GetPageByIndex<T>
ValueTask<IndexedDbRecord<T>[]> GetPageByIndex<T>(string store, string index, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)
Walks an index in index-key order. NextUnique collapses duplicates to one record per key.
Handle.GetKeyPageByIndex
ValueTask<IndexedDbKeyRecord[]> GetKeyPageByIndex(string store, string index, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)
Keys-only index walk. With NextUnique, this enumerates the distinct values an index holds.
Handle.Transact
ValueTask<JsonElement[]> Transact(IndexedDbOperation[] operations, IndexedDbTransactionMode mode = ReadWrite, IndexedDbDurability durability = Default)
Runs the batch as one atomic transaction across every store it touches, returning each operation's resulting key.
Handle.GetInfo / GetStoreInfo / GetIndexInfo
ValueTask<IndexedDbDatabaseInfo?> GetInfo() | ValueTask<IndexedDbStoreInfo?> GetStoreInfo(string store) | ValueTask<IndexedDbIndexInfo?> GetIndexInfo(string store, string index)
Reads back the live schema - version, store list, keypaths, index flags.
Handle.DisposeAsync
ValueTask DisposeAsync()
Closes the underlying IDBDatabase connection. Idempotent.
IndexedDbKeyRange
static IndexedDbKeyRange Only(object) | LowerBound(object, bool open = false) | UpperBound(object, bool open = false) | Bound(object, object, bool lowerOpen = false, bool upperOpen = false)
An interval over keys. Accepted anywhere a key is.
IndexedDbOperation
static IndexedDbOperation Put<T>(string store, T value, object? key = null) | Add<T>(...) | Delete(string store, object query) | Clear(string store)
One write inside a Transact batch.
IndexedDbCursorDirection
enum { Next, NextUnique, Previous, PreviousUnique }
Order a cursor walks in. The Unique variants yield one record per distinct key.
IndexedDbDurability
enum { Default, Relaxed, Strict }
How hard the browser tries to flush a transaction to disk before reporting it complete.
IndexedDbRecord<T> / IndexedDbKeyRecord
class IndexedDbRecord<T> { JsonElement Key; JsonElement PrimaryKey; T? Value; }
One record from a cursor walk. Key is the index key when walking an index, otherwise the primary key.
IndexedDbStoreSchema
class IndexedDbStoreSchema { string Name; string? KeyPath; string[]? KeyPaths; bool AutoIncrement; bool Drop; IndexedDbIndexSchema[] Indexes; }
Object-store definition supplied to Open. Null KeyPath means out-of-line keys; KeyPaths declares a compound key; Drop removes the store during the upgrade.
IndexedDbIndexSchema
class IndexedDbIndexSchema { string Name; string KeyPath; string[]? KeyPaths; bool Unique; bool MultiEntry; bool Drop; }
Index definition created alongside the store. A changed definition is dropped and re-created; Drop removes it.
An unhandled error has occurred. Reload 🗙