← Back to Lessons Lesson 32 of 48
Intermediate api

Wasm + IndexedDB

What Is IndexedDB?

IndexedDB is a low-level, transactional database built into every browser. Unlike localStorage (which only stores strings up to ~5 MB), IndexedDB can store structured data, binary blobs, and files with virtually no size limit.

┌─────────────────────────────────────────────────────┐
│                    Browser                           │
│                                                      │
│  ┌─────────────┐   ┌────────────────────────────┐   │
│  │  Wasm Module │──►│       IndexedDB             │   │
│  │  (Rust)      │   │                            │   │
│  │              │   │  Database: "my_app"         │   │
│  │  web-sys     │   │  ├── Store: "users"        │   │
│  │  bindings    │   │  │   ├── Index: "email"    │   │
│  │              │   │  │   └── Index: "name"     │   │
│  │  JsFuture    │   │  └── Store: "settings"     │   │
│  └─────────────┘   └────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
Feature localStorage IndexedDB
Data types Strings only Structured, binary, blobs
Size limit ~5-10 MB Hundreds of MB+
Async No (blocking) Yes
Indexes No Yes
Transactions No Yes (ACID)
Cursors No Yes

Setting Up web-sys for IndexedDB

[dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"

[dependencies.web-sys]
version = "0.3"
features = [
    "Window",
    "IdbFactory",
    "IdbOpenDbRequest",
    "IdbDatabase",
    "IdbTransaction",
    "IdbTransactionMode",
    "IdbObjectStore",
    "IdbObjectStoreParameters",
    "IdbRequest",
    "IdbKeyRange",
    "IdbIndex",
    "IdbIndexParameters",
    "IdbCursorWithValue",
    "IdbCursorDirection",
    "DomException",
    "Event",
    "IdbVersionChangeEvent",
]

Opening a Database

IndexedDB operations are callback-based in JavaScript. In Rust, we wrap them with JsFuture or manual Promise handling:

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{IdbDatabase, IdbOpenDbRequest, IdbObjectStoreParameters};
use wasm_bindgen_futures::JsFuture;

pub async fn open_db(name: &str, version: u32) -> Result<IdbDatabase, JsValue> {
    let window = web_sys::window().unwrap();
    let idb_factory = window.indexed_db()?.unwrap();

    let open_request: IdbOpenDbRequest = idb_factory.open_with_u32(name, version)?;

    // Handle schema upgrades
    let on_upgrade = Closure::wrap(Box::new(move |event: web_sys::IdbVersionChangeEvent| {
        let request: IdbOpenDbRequest = event.target().unwrap().dyn_into().unwrap();
        let db: IdbDatabase = request.result().unwrap().dyn_into().unwrap();

        // Create object stores during upgrade
        if !db.object_store_names().contains("users") {
            let mut params = IdbObjectStoreParameters::new();
            params.key_path(Some(&JsValue::from_str("id")));
            params.auto_increment(true);

            let store = db.create_object_store_with_optional_parameters("users", &params)?;
            store.create_index_with_str("email", "email")?;
            store.create_index_with_str("name", "name")?;
        }
    }) as Box<dyn FnMut(_)>);

    open_request.set_onupgradeneeded(Some(on_upgrade.as_ref().unchecked_ref()));
    on_upgrade.forget();

    // Wait for the database to open
    let result = JsFuture::from(open_request.into()).await?;
    Ok(result.dyn_into()?)
}

CRUD Operations

Create (put)

use serde::Serialize;

#[derive(Serialize)]
struct User {
    name: String,
    email: String,
    age: u32,
}

pub async fn add_user(db: &IdbDatabase, user: &User) -> Result<JsValue, JsValue> {
    let transaction = db.transaction_with_str_and_mode(
        "users",
        web_sys::IdbTransactionMode::Readwrite,
    )?;

    let store = transaction.object_store("users")?;
    let js_value = serde_wasm_bindgen::to_value(user)?;
    let request = store.put(&js_value)?;

    JsFuture::from(request).await
}

Read (get)

pub async fn get_user(db: &IdbDatabase, id: u32) -> Result<Option<JsValue>, JsValue> {
    let transaction = db.transaction_with_str("users")?;
    let store = transaction.object_store("users")?;
    let request = store.get(&JsValue::from(id))?;

    let result = JsFuture::from(request).await?;
    if result.is_undefined() {
        Ok(None)
    } else {
        Ok(Some(result))
    }
}

Update

// put() with an existing key updates the record
pub async fn update_user(db: &IdbDatabase, user: &User) -> Result<JsValue, JsValue> {
    // Same as add_user — put() is an upsert operation
    add_user(db, user).await
}

Delete

pub async fn delete_user(db: &IdbDatabase, id: u32) -> Result<(), JsValue> {
    let transaction = db.transaction_with_str_and_mode(
        "users",
        web_sys::IdbTransactionMode::Readwrite,
    )?;

    let store = transaction.object_store("users")?;
    let request = store.delete(&JsValue::from(id))?;
    JsFuture::from(request).await?;
    Ok(())
}

Transactions

IndexedDB transactions are ACID-compliant:

┌─────────────────────────────────────────┐
│            Transaction                   │
│                                          │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐ │
│  │  put()   │  │  get()   │  │ delete() │ │
│  │ user #1  │  │ user #2  │  │ user #3  │ │
│  └─────────┘  └─────────┘  └─────────┘ │
│                                          │
│  All succeed ──► COMMIT                  │
│  Any fails   ──► ROLLBACK (all undone)   │
└─────────────────────────────────────────┘

Transaction modes:

Mode Can Read? Can Write? Blocks Others?
readonly Yes No No
readwrite Yes Yes Blocks writes to same stores
versionchange Yes Yes (schema) Exclusive
// Multiple operations in one transaction
pub async fn transfer_credits(
    db: &IdbDatabase,
    from_id: u32,
    to_id: u32,
    amount: u32,
) -> Result<(), JsValue> {
    let tx = db.transaction_with_str_and_mode(
        "users", web_sys::IdbTransactionMode::Readwrite,
    )?;
    let store = tx.object_store("users")?;

    // Both operations happen atomically
    let from = JsFuture::from(store.get(&from_id.into())?).await?;
    let to = JsFuture::from(store.get(&to_id.into())?).await?;

    // Modify and put back
    js_sys::Reflect::set(&from, &"credits".into(),
        &(get_credits(&from) - amount).into())?;
    js_sys::Reflect::set(&to, &"credits".into(),
        &(get_credits(&to) + amount).into())?;

    store.put(&from)?;
    store.put(&to)?;

    // Transaction auto-commits when all requests complete
    Ok(())
}

Using Indexes for Queries

Indexes let you look up records by fields other than the primary key:

pub async fn find_by_email(
    db: &IdbDatabase,
    email: &str,
) -> Result<Option<JsValue>, JsValue> {
    let tx = db.transaction_with_str("users")?;
    let store = tx.object_store("users")?;
    let index = store.index("email")?;

    let request = index.get(&JsValue::from_str(email))?;
    let result = JsFuture::from(request).await?;

    if result.is_undefined() { Ok(None) } else { Ok(Some(result)) }
}

Range Queries with IdbKeyRange

pub async fn find_users_in_age_range(
    db: &IdbDatabase,
    min_age: u32,
    max_age: u32,
) -> Result<Vec<JsValue>, JsValue> {
    let tx = db.transaction_with_str("users")?;
    let store = tx.object_store("users")?;
    let index = store.index("age")?;

    let range = web_sys::IdbKeyRange::bound(
        &JsValue::from(min_age),
        &JsValue::from(max_age),
    )?;

    let request = index.get_all_with_key(&range)?;
    let result = JsFuture::from(request).await?;
    let array: js_sys::Array = result.dyn_into()?;

    Ok(array.iter().collect())
}

JsFuture: Bridging Callbacks to Async

IndexedDB was designed before Promises existed, so it uses event callbacks. JsFuture converts IdbRequest into a Rust Future:

┌────────────┐     ┌──────────────┐     ┌────────────┐
│ IdbRequest │────►│   Promise    │────►│  JsFuture   │
│ (callback) │     │  (wrapper)   │     │  (.await)   │
│            │     │              │     │             │
│ onsuccess  │     │  resolve()   │     │  Ok(value)  │
│ onerror    │     │  reject()    │     │  Err(error) │
└────────────┘     └──────────────┘     └────────────┘
use wasm_bindgen_futures::JsFuture;

// IdbRequest -> JsFuture (via implicit Promise conversion)
let request: IdbRequest = store.get(&key)?;
let result: JsValue = JsFuture::from(request).await?;

Error Handling

pub async fn safe_db_operation(db: &IdbDatabase) -> Result<String, String> {
    let tx = db.transaction_with_str_and_mode(
        "users",
        web_sys::IdbTransactionMode::Readwrite,
    ).map_err(|e| format!("Transaction failed: {:?}", e))?;

    let store = tx.object_store("users")
        .map_err(|e| format!("Store not found: {:?}", e))?;

    let request = store.get(&JsValue::from(1))
        .map_err(|e| format!("Get failed: {:?}", e))?;

    let result = JsFuture::from(request).await
        .map_err(|e| format!("Async error: {:?}", e))?;

    if result.is_undefined() {
        Err("Record not found".to_string())
    } else {
        Ok(format!("{:?}", result))
    }
}

Key Takeaways

  1. IndexedDB is a powerful browser database — transactional, indexed, handles binary data and large datasets
  2. web-sys provides full IndexedDB bindings, but the API is verbose and callback-based
  3. JsFuture bridges the callback-based API to Rust async/await
  4. Transactions are ACID-compliant — group related operations for atomicity
  5. Indexes enable efficient lookups on non-primary-key fields
  6. Schema upgrades happen in the onupgradeneeded event — this is the only place you can create/delete stores and indexes
  7. Always handle errors carefully — IndexedDB operations can fail due to quota limits, permissions, or schema mismatches

Try It