Web (Browser / WASM)
Run a full vector and document database in the browser tab. Store and query application data, then pair TalaDB with an on-device embedding model (transformers.js, ONNX Runtime Web) to add semantic search, BM25 full-text, and hybrid RAG retrieval with no server, no API key, and no data leaving the user's machine.
TalaDB runs in the browser as a WebAssembly module compiled from the same Rust core used on every other platform. Data is persisted to the Origin Private File System (OPFS) — a fast, private storage area built into modern browsers.
Browser support
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
| WASM + SIMD (required to load) | 91+ | 89+ | 16.4+ |
| OPFS (fastest persistence) | 91+ | 111+ | 16.4+ |
| IndexedDB fallback (persistence without OPFS) | 91+ | 89+ | 16.4+ |
The module is built with WebAssembly SIMD (+simd128), which the vector scoring loops rely on — without it the browser runs them scalar and vector search is roughly twice as slow. A WASM module using SIMD does not instantiate at all on an engine that lacks it, so SIMD support, not OPFS, sets the floor in every row above. Safari gained it in 16.4; Chrome in 91 and Firefox in 89, both of which predate their OPFS support.
On browsers without OPFS, TalaDB automatically falls back to an IndexedDB-backed in-memory database. Data still persists across page reloads. Snapshots are written to IndexedDB with a short debounce so bulk inserts stay fast.
Installation
pnpm add taladb @taladb/webVite setup
Add two things to vite.config.ts:
- Exclude
taladband@taladb/webfrom dependency pre-bundling - Set the COOP/COEP headers required for the OPFS Worker context
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['taladb', '@taladb/web'],
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless',
},
},
})Next.js setup
Turbopack — the default for next dev and next build since Next 16 — needs no configuration at all from 0.11.1 onward. Install, mount the provider on the client, and you are done. The rest of this section is the things that are easy to get wrong, in the order they bite.
The provider is client-only, and so is everything under it
TalaDBProvider opens the database in an effect, and effects do not run on the server. It renders its fallback during SSR, which means nothing beneath it appears in the server-rendered HTML, and there is no dehydrate / HydrationBoundary equivalent to prefetch through.
So do not wrap app/layout.tsx, and do not wrap a page whose content has to be indexed. Mount it around the smallest subtree that reads from TalaDB:
// app/providers/taladb.tsx
'use client'
import { TalaDBProvider } from '@taladb/react'
export function LocalData({ children }: { children: React.ReactNode }) {
return (
<TalaDBProvider name="myapp.db" collections={{ books: {} }} fallback={null}>
{children}
</TalaDBProvider>
)
}To keep a page server-rendered and leave a local copy behind, render the server's data as HTML and hydrate it into TalaDB from a client component that renders null. The page stays indexable; the collection ends up warm.
Content-Security-Policy
The engine is WebAssembly running inside a Worker, so a CSP needs both of these or the database never opens:
script-src 'self' 'wasm-unsafe-eval';
worker-src 'self' blob:;Webpack, if you opt out of Turbopack
next dev --webpack / next build --webpack needs help that Turbopack does not. Webpack only follows the inline new Worker(new URL(…, import.meta.url)) form, and the browser build spawns its worker from a variable — so it copies the worker out verbatim without ever bundling the worker's own import('../pkg/taladb_web.js'). At runtime that relative URL resolves to /_next/static/pkg/taladb_web.js, which nothing has emitted. The symptom is a white screen on a cold start.
Emit both files there:
// next.config.ts
webpack(config, { isServer }) {
if (isServer) return config
config.experiments = { ...config.experiments, asyncWebAssembly: true }
config.module.rules.push({
test: /\.wasm$/,
type: 'asset/resource',
generator: { filename: 'static/wasm/[name][ext]' },
})
const path = require('path')
const fs = require('fs')
const pkgDir = path.dirname(require.resolve('@taladb/web/pkg/taladb_web.js'))
config.plugins.push({
apply(compiler) {
const { Compilation, sources } = compiler.webpack
compiler.hooks.thisCompilation.tap('EmitTaladbPkg', (compilation) => {
compilation.hooks.processAssets.tap(
{ name: 'EmitTaladbPkg', stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL },
() => {
for (const file of ['taladb_web.js', 'taladb_web_bg.wasm']) {
if (compilation.getAsset(`static/pkg/${file}`)) continue
compilation.emitAsset(
`static/pkg/${file}`,
new sources.RawSource(fs.readFileSync(path.join(pkgDir, file))),
)
}
},
)
})
},
})
return config
}transpilePackages
Not required, but harmless and occasionally useful if another tool in your chain chokes on the published ESM:
transpilePackages: ['taladb', '@taladb/web', '@taladb/react']Weight
The engine is roughly 570 kB gzipped of WebAssembly, fetched when the database first opens. That is a real cost on a landing page, and a JS-only bundle budget will not see it. On a page where TalaDB is not needed for the first paint, mount the provider after the page is interactive:
const [ready, setReady] = useState(false)
useEffect(() => {
const id = requestIdleCallback(() => setReady(true), { timeout: 5000 })
return () => cancelIdleCallback(id)
}, [])
if (!ready) return nullReact StrictMode
Safe from 0.11.1. Handles are shared and reference-counted per database name, so StrictMode's double-mount opens one instance rather than two. On 0.11.0 and earlier it opened two, the second lost the OPFS lock, fell back to an IndexedDB snapshot, and silently dropped its writes — if you are pinned there, set reactStrictMode: false.
Linking a local checkout
Two settings, and Next requires them to agree — it warns and then silently prefers outputFileTracingRoot, so changing only one looks applied but is not:
outputFileTracingRoot: path.join(__dirname, '../..'),
turbopack: { root: path.join(__dirname, '../..') },Both must be an ancestor of the linked checkout, or Turbopack reports Can't resolve 'taladb'. Note also that link: brings the checkout's own node_modules along — a second copy of @types/react there will clash with yours. A pnpm pack tarball installed by file: avoids both problems.
Quick start
import { openDB } from 'taladb'
const db = await openDB('myapp.db')
const users = db.collection('users')
await users.insert({ name: 'Alice', age: 30 })
const all = await users.find()openDB detects the browser automatically, opens a persistent OPFS database named myapp.db, and returns a collection API identical to Node.js and React Native.
Defining your schema
TalaDB is schemaless, but TypeScript generics let you describe the shape of each collection:
interface User {
_id?: string
name: string
email: string
age: number
role: 'user' | 'admin'
createdAt: number
}Basic CRUD
const users = db.collection<User>('users')
// Create indexes at startup — idempotent, safe to call on every open
await users.createIndex('email')
await users.createIndex('age')
// Insert — returns the generated ULID string
const id = await users.insert({
name: 'Alice',
email: 'alice@example.com',
age: 30,
role: 'user',
createdAt: Date.now(),
})
// Insert many
await users.insertMany([
{ name: 'Bob', email: 'bob@example.com', age: 25, role: 'user', createdAt: Date.now() },
{ name: 'Carol', email: 'carol@example.com', age: 35, role: 'admin', createdAt: Date.now() },
])
// Find all
const everyone = await users.find()
// Find with filter — uses index automatically
const adults = await users.find({ age: { $gte: 18 } })
// Find one
const alice = await users.findOne({ email: 'alice@example.com' })
// Count
const adminCount = await users.count({ role: 'admin' })
// Update
await users.updateOne({ email: 'alice@example.com' }, { $set: { age: 31 } })
await users.updateMany({ role: 'user' }, { $set: { verified: true } })
// Delete
await users.deleteOne({ email: 'alice@example.com' })
await users.deleteMany({ role: 'banned' })Queries
// Range
const thirties = await users.find({ age: { $gte: 30, $lte: 39 } })
// OR — uses IndexOr plan when both fields are indexed
const staff = await users.find({
$or: [{ role: 'admin' }, { role: 'moderator' }],
})
// Membership test
const team = await users.find({ role: { $in: ['admin', 'moderator', 'editor'] } })
// Compound AND
const activeAdults = await users.find({
$and: [{ age: { $gte: 18 } }, { role: { $ne: 'banned' } }],
})Full-text & hybrid search
Create an FTS index on a string field, then rank by relevance with searchText (BM25), gate booleanly with $contains, or combine keyword + vector ranking with hybridSearch:
const posts = db.collection<Post>('posts')
// Create once at startup — idempotent
await posts.createFtsIndex('body')
// Ranked keyword search (best matches first)
const hits = await posts.searchText('body', 'embedded rust database', 5)
// Boolean filter (every token must appear)
const all = await posts.find({ body: { $contains: 'taladb' } })Pair an FTS index with a vector index and use hybridSearch for RAG retrieval that runs entirely in the browser. See the Search reference.
Inspecting indexes
const { btree, fts, vector } = await users.listIndexes()
// btree: ['email', 'age']
// fts: []
// vector: []Live queries in React
subscribe fires the callback immediately with the current results, then again after any write that could affect the result set. Call the returned function to unsubscribe.
import { useEffect, useState } from 'react'
import { openDB, type Collection, type Document } from 'taladb'
function useLiveQuery<T extends Document>(col: Collection<T>, filter = {}) {
const [docs, setDocs] = useState<T[]>([])
useEffect(() => {
const unsub = col.subscribe(filter, setDocs)
return unsub
}, [])
return docs
}
// Usage
const admins = useLiveQuery(db.collection<User>('users'), { role: 'admin' })Vector search
Store and search embeddings from an on-device model — no cloud API, no data leaving the browser.
import { pipeline } from '@huggingface/transformers'
// Model is downloaded and cached on first use (~25 MB for MiniLM)
const embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
async function embed(text: string): Promise<number[]> {
const out = await embedder(text, { pooling: 'mean', normalize: true })
return Array.from(out.data) as number[]
}interface Article {
_id?: string
title: string
body: string
category: string
embedding: number[]
}
const articles = db.collection<Article>('articles')
// Create once at startup — idempotent
await articles.createVectorIndex('embedding', { dimensions: 384 })
// Insert with embedding
await articles.insert({
title: 'Getting started',
body: '...',
category: 'guide',
embedding: await embed('Getting started'),
})
// Semantic search
const queryVec = await embed('how do I begin')
const results = await articles.findNearest('embedding', queryVec, 5)
results.forEach(({ document, score }) => {
console.log(`${score.toFixed(3)} ${document.title}`)
})
// Filtered vector search: filter first, then rank — one call, no extra round-trips
const filtered = await articles.findNearest('embedding', queryVec, 5, {
category: 'guide',
})Similarity metrics
| Metric | Best for | Score range |
|---|---|---|
cosine (default) | Text embeddings, normalised vectors | [-1, 1] |
dot | Embeddings where magnitude matters | Unbounded |
euclidean | Spatial / coordinate data | (0, 1] |
Multi-tab behaviour
TalaDB uses the Web Locks API to coordinate database access across tabs sharing the same origin.
One DedicatedWorker owns the database under an exclusive Web Lock. Other tabs send all reads and writes, index changes, version changes, and flushes to that worker over BroadcastChannel. They keep no in-memory database replica. An awaited operation returns the owner's result, including duplicate-ID and persistence errors. Reads issued after an awaited write see that write.
When the owner closes, a waiting worker acquires the lock and opens committed storage. Requests carry an owner epoch. Outstanding requests can reject with an unknown outcome if the owner disappears after committing but before replying; the worker does not replay them. Applications should reconcile by document ID before retrying non-idempotent updates such as $inc.
OPFS writes update the file directly; they do not export the database to IDB. When OPFS APIs are absent, the single owner uses a 32 MiB limited snapshot backend in IndexedDB. Default durability acknowledges writes only after the IDB transaction completes. With flush_every_write: false, writes are eventual; await db.flush() is the durability barrier. Quota, read, and flush errors are reported. An OPFS permission or file-open failure rejects opening the database rather than selecting a different store.
Web Locks are required for persistent browser access. Additional tabs require BroadcastChannel and identical database configurations. Encrypted databases remain single-tab. Database names must be nonempty and cannot contain /, \, or :. Close all tabs using an older worker before upgrading: the old snapshot protocol is not compatible with owner RPC.
Ownership and storage status
const status = await db.storageInfo?.()
// { storage: 'opfs' | 'indexeddb', durableWrites, maxSnapshotBytes,
// storageError, hnsw: false, owner }
const ownsStorage = await db.isPrimary?.()isPrimary() reports which tab currently owns storage; every tab receives authoritative query results. Ownership may change immediately after this check, so it is not an application-level lock for an outbox drainer or a scheduled job. Use a separate Web Lock for exclusive application work. Database migrations provided to openDB already use a separate lock to serialize migration runners.
Change webhook
Report every committed write to a backend over HTTP:
import { openDB } from 'taladb'
const db = await openDB('myapp.db', {
webhook: {
enabled: true,
endpoint: 'https://api.example.com/taladb',
headers: { Authorization: `Bearer ${myToken}` },
exclude_fields: ['embedding'], // omit large vector fields from payloads
},
})
// Every write now fires an HTTP request after the commit
const users = db.collection('users')
await users.insert({ name: 'Alice', role: 'admin' }) // → POSTPOST on insert, PUT on update, DELETE on delete. Requests are dispatched after the commit with 3 retries and exponential backoff (200 / 400 / 800 ms) on 5xx and network errors. Writes are never blocked.
Tab lifetime
Delivery is best effort. Closing the tab can lose queued events; a lost response can cause a retry. Retries carry a stable event_id and Idempotency-Key so the receiver can deduplicate them.
Drain the queue at a moment you control:
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') void db.flushWebhook?.(2_000)
})
db.webhookStats?.() // { pending, delivered, failed, dropped }db.close() drains automatically.
Per-op endpoint overrides are supported:
const db = await openDB('myapp.db', {
webhook: {
enabled: true,
endpoint: 'https://api.example.com/events',
insert_endpoint: 'https://api.example.com/events/insert',
update_endpoint: 'https://api.example.com/events/update',
delete_endpoint: 'https://api.example.com/events/delete',
},
})See the Change Webhook reference for the full config reference, payload shapes, and delivery semantics.
Migrations
Run schema changes at open time — each migration runs once, in order, atomically:
const db = await openDB('myapp.db', {
migrations: [
{
version: 1,
description: 'Add email index',
up: async (db) => {
await db.collection('users').createIndex('email')
},
},
{
version: 2,
description: 'Backfill role field',
up: async (db) => {
const users = db.collection('users')
for (const user of await users.find({})) {
if (!user.role) {
await users.updateOne({ _id: user._id }, { $set: { role: 'user' } })
}
}
},
},
],
})Exporting a snapshot
Not available through openDB()
Snapshot export lives on the raw @taladb/web WASM handle, not on the database object openDB() returns, and the Node binding does not expose it at all. The example below uses the binding directly.
import init, { TalaDbWeb } from '@taladb/web'
await init()
const raw = TalaDbWeb.open('myapp.db')
// Export the whole database to a Uint8Array
const bytes = raw.exportSnapshot()
// Save as a file download
const blob = new Blob([bytes], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = Object.assign(document.createElement('a'), { href: url, download: 'myapp.taladb' })
a.click()Closing the database
await db.close()Calling close() flushes any pending IDB snapshot, releases the Web Lock, and allows another tab to acquire the OPFS file as the new primary.
Current limitations
Vector build scheduling — persistent HNSW indexes work in browsers and Web Workers without native threads. Use
rebuildVectorIndex()for a batched, cancellable build so each batch yields to the browser event loop. A direct synchronous Wasm rebuild can still block the current thread on a large index.Snapshot size —
exportSnapshotand the IndexedDB fallback path serialise the entire database to aUint8Arrayin Wasm memory. This works well for databases under ~50 MB. Beyond that, the serialisation overhead becomes noticeable. OPFS-backed storage (the default when OPFS is available) is not affected — it writes directly to the file with no in-memory copy.