← 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", ¶ms)?;
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
- IndexedDB is a powerful browser database — transactional, indexed, handles binary data and large datasets
- web-sys provides full IndexedDB bindings, but the API is verbose and callback-based
- JsFuture bridges the callback-based API to Rust async/await
- Transactions are ACID-compliant — group related operations for atomicity
- Indexes enable efficient lookups on non-primary-key fields
- Schema upgrades happen in the
onupgradeneededevent — this is the only place you can create/delete stores and indexes - Always handle errors carefully — IndexedDB operations can fail due to quota limits, permissions, or schema mismatches