Running LoraDB in the Browser with WebAssembly
Overview
lora-wasm runs the full LoraDB engine in the browser (or Node) via
WebAssembly. The surface, helpers, and type guards match
lora-node for query execution and typed values. Snapshot
persistence is pathless in WASM: save returns bytes or web-native
objects, and load consumes byte/source objects. For browser apps,
prefer the Worker variant so the main thread stays responsive.
Installation / Setup
Targets
lora-wasm ships three targets out of the same source:
| Target | Use in | Entry |
|---|---|---|
| Node | Server-side JS, tests, scripts | import { createDatabase } from '@loradb/lora-wasm' |
| Bundler | Vite / webpack / esbuild | import { createDatabase } from '@loradb/lora-wasm/bundler' |
| Web | Raw <script type=module> | import { createDatabase } from '@loradb/lora-wasm/web' |
Requirements
- Node.js 20+ for building / testing
- A bundler (Vite, webpack, esbuild, Rollup) for browser usage, or
a host that serves
.wasmwith the correct MIME type.
Install
npm install @loradb/lora-wasm
Creating a Client / Connection
In-process (Node or bundler)
lora-wasm is async-only. The one supported initialization
pattern is createDatabase():
import { createDatabase } from '@loradb/lora-wasm';
const db = await createDatabase();
createDatabase() is the single entry point — there is no
synchronous constructor and no Database.create() static. It
bootstraps the WASM module on the first call, so the engine is
guaranteed to be ready before the first query runs. Every method
on the returned instance returns a Promise for API symmetry with
lora-node and the Worker variant.
Unlike lora-node, the WASM binding does not accept a directory
string for persistent initialization. createDatabase() is always an
in-memory database; persistency in WASM is byte-based through
saveSnapshot / loadSnapshot.
awaitcreateDatabase() returns a Promise. Calling execute() on the
unresolved promise will throw. Always await the factory before
running queries, and never instantiate the Database type
directly — it is exported as a type only.
Browser Worker (recommended)
// src/worker.ts
import 'lora-wasm/worker';
// src/main.ts
import { createWorkerDatabase } from '@loradb/lora-wasm/worker-client';
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
const db = createWorkerDatabase(worker);
WorkerDatabase has the same surface as Database (execute,
clear, nodeCount, relationshipCount, saveSnapshot, and
loadSnapshot). Every call posts a message to the worker and awaits
the reply, so the main thread never blocks on the engine.
Running Your First Query
import { createDatabase } from '@loradb/lora-wasm';
const db = await createDatabase();
await db.execute("CREATE (:Person {name: 'Ada'})");
const res = await db.execute("MATCH (n:Person) RETURN n.name AS name");
console.log(res.rows); // [ { name: 'Ada' } ]
Note: inside WASM, queries execute synchronously — the Promise resolves on the same microtask tick. For heavy queries in the browser, use the Worker variant.
Examples
Minimal working example
Shown above.
Parameterised query
const res = await db.execute(
"MATCH (u:User) WHERE u.handle = $handle RETURN u.id AS id",
{ handle: 'alice' }
);
Explain and profile
explain and profile are explicit binding methods, not Cypher
keywords inside the query string. db.explain(...) compiles the query
and returns the physical plan without invoking the executor:
const plan = await db.explain(
"MATCH (u:User) WHERE u.handle = $handle RETURN u",
{ handle: 'alice' }
);
console.log(plan.shape); // "readOnly" or "mutating"
console.log(plan.resultColumns); // ["u"]
console.log(plan.tree.operator);
The plan tree uses the same JSON-compatible shape as Node:
id, operator, details, estimatedRows, and children.
details is intentionally human-readable and opaque; don't parse it
as a stable machine contract.
db.profile(...) runs the query and returns the plan plus per-operator
metrics:
const profile = await db.profile(
"MATCH (u:User) WHERE u.handle = $handle RETURN u",
{ handle: 'alice' }
);
console.log(profile.metrics.totalElapsedNs);
console.log(profile.metrics.totalRows);
console.log(profile.metrics.perOperator);
profile executes the queryMutating queries passed to profile produce the same side effects as
execute. Use explain to inspect a mutating CREATE, MERGE, SET,
DELETE, or REMOVE plan without changing the graph.
Both methods accept the same parameter values as execute, including
tagged helper structs for temporal, spatial, vector, and binary values.
Graph structs such as LoraNode are returned by queries; for input,
pass property values or typed helper values:
import { date, wgs84 } from '@loradb/lora-wasm';
const params = {
since: date('1800-01-01'),
near: wgs84(4.89, 52.37),
radius: 5000,
};
await db.explain(
`MATCH (c:City)
WHERE c.founded >= $since
AND geo.distance(c.location, $near) < $radius
RETURN c.name AS name`,
params
);
await db.profile(
`MATCH (c:City)
WHERE c.founded >= $since
AND geo.distance(c.location, $near) < $radius
RETURN c.name AS name`,
params
);
Structured result handling (typed helpers)
import { createDatabase, wgs84 } from '@loradb/lora-wasm';
const db = await createDatabase();
await db.execute(
"CREATE (:City {name: $name, location: $loc})",
{ name: 'Amsterdam', loc: wgs84(4.89, 52.37) }
);
See the Node guide → typed helpers —
date, duration, cartesian, wgs84, … export from both
packages with identical signatures.
React + Worker example
// src/worker.ts
import 'lora-wasm/worker';
// src/useDb.ts
import { createWorkerDatabase, type WorkerDatabase } from '@loradb/lora-wasm/worker-client';
import { useEffect, useState } from 'react';
let dbPromise: Promise<WorkerDatabase> | null = null;
function getDb() {
if (!dbPromise) {
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
dbPromise = Promise.resolve(createWorkerDatabase(worker));
}
return dbPromise;
}
export function useUserCount() {
const [n, setN] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const db = await getDb();
const { rows } = await db.execute(
"MATCH (u:User) RETURN count(*) AS n"
);
if (!cancelled) setN(rows[0].n as number);
})();
return () => { cancelled = true; };
}, []);
return n;
}
The main thread posts messages; the engine runs in the Worker; the UI stays interactive.
Handle errors
try {
await db.execute("BAD QUERY");
} catch (err) {
// WASM surfaces engine errors as plain Error objects
console.error((err as Error).message);
}
Browser constraints and concurrency
- WASM execution is synchronous inside the Worker — a heavy query blocks the worker thread, not the UI. Use one Worker per independent read path for concurrency.
Databaseinstances in the main thread and in a Worker have separate graphs — WASM instances don't share memory. Useexecuteto serialise data between them if you need to sync.- Shared-memory WASM (SAB + threaded wasm-bindgen) is not supported.
Common Patterns
Persisting your graph
The browser WASM binding has no filesystem, so the snapshot API is
source-in / byte-out and never accepts a string path. By default,
save produces a Uint8Array; load accepts URL, Uint8Array,
ArrayBuffer, Blob, Response, or a
ReadableStream<Uint8Array | ArrayBuffer>. Store the bytes wherever
your app already stores state — IndexedDB, the fetch API, OPFS, or a
backend:
// Dump the full graph to bytes.
const bytes: Uint8Array = await db.saveSnapshot();
// Later (same or next session), restore from bytes.
await db.loadSnapshot(bytes);
Other output formats are available when they fit the surrounding platform better:
const blob = await db.saveSnapshot({ format: 'blob' });
const response = await db.saveSnapshot({ format: 'response' });
const url = await db.saveSnapshot({ format: 'url' });
Compression and encryption are supported in WASM too:
const encryption = {
type: 'password',
keyId: 'browser-backup',
password: userSuppliedPassword,
};
const bytes = await db.saveSnapshot({
compression: { format: 'gzip', level: 1 },
encryption,
});
await db.loadSnapshot(bytes, { credentials: encryption });
The Node target of @loradb/lora-wasm exposes the same pathless API
for parity. Use the filesystem-backed saveSnapshot(path) on
@loradb/lora-node only when you want a path-based API. The
Worker-backed surface (createWorkerDatabase) exposes the same
saveSnapshot / loadSnapshot methods and runs the work off the main
thread.
See the canonical Snapshots guide for the full metadata shape and atomic-rename guarantees (the latter apply to path-based writes in the other bindings; byte-based persistence is atomic only as far as the surrounding storage layer allows).
Persist across reloads with IndexedDB
const DB = 'loradb-snapshots', STORE = 'graph', KEY = 'main';
async function idb(): Promise<IDBDatabase> {
return await new Promise((ok, err) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore(STORE);
r.onsuccess = () => ok(r.result);
r.onerror = () => err(r.error);
});
}
async function saveToIdb(db: Database) {
const bytes = await db.saveSnapshot();
const idbDb = await idb();
await new Promise<void>((ok, err) => {
const tx = idbDb.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(bytes, KEY);
tx.oncomplete = () => ok();
tx.onerror = () => err(tx.error);
});
}
async function loadFromIdb(db: Database) {
const idbDb = await idb();
const bytes = await new Promise<Uint8Array | undefined>((ok, err) => {
const tx = idbDb.transaction(STORE, 'readonly');
const r = tx.objectStore(STORE).get(KEY);
r.onsuccess = () => ok(r.result);
r.onerror = () => err(r.error);
});
if (bytes) await db.loadSnapshot(bytes);
}
Run heavy queries without blocking the UI
Use the Worker variant — see Browser Worker (recommended) above. Every call posts a message and awaits the reply, so the main thread stays interactive.
Bundler notes
Vite
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: { exclude: ['lora-wasm'] },
worker: { format: 'es' },
});
webpack / Next.js
Ensure .wasm is served with Content-Type: application/wasm. For
Next.js, mark the package as serverExternalPackages if you use it
only on the edge / server.
Raw browser
The /web subpath loads .wasm relative to the current page.
You'll need to serve the package files unmodified.
Methods
await db.execute(query, params?); // returns { columns, rows }
await db.clear();
await db.nodeCount();
await db.relationshipCount();
await db.saveSnapshot();
await db.loadSnapshot(source);
db.dispose(); // release the WASM handle
dispose() drops the underlying WASM reference. After calling it,
further execute calls will throw.
Common initialization mistakes
| ❌ Wrong | ✅ Right |
|---|---|
const db = new Database() | const db = await createDatabase() |
await init(); const db = new Database() | const db = await createDatabase() (init is handled inside) |
const db = Database.create() (missing await) | const db = await createDatabase() |
Database.create() (legacy name) | createDatabase() |
await db.loadSnapshot('/tmp/graph.bin') | Fetch or read the bytes first, then await db.loadSnapshot(bytes) |
Database is a type-only export in lora-wasm. Importing it
as a value and calling new Database() is a compile error —
synchronous initialization has been removed so the WASM module
can never be queried before it is bootstrapped.
Error Handling
WASM surfaces engine errors as plain Error with the engine's
message. There is no structured error class equivalent to
lora-node's LoraError — match on the message text or let it
bubble to a generic handler.
Performance / Best Practices
- Single runtime by default. Auto-commit reads can overlap on engine snapshots; write commits serialize. Use multiple Workers when you want separate graphs or stronger UI isolation.
- Integer precision. Same 2^53 limit as
lora-node—i64values outside the safe integer range lose precision. - Wall-clock resolution.
temporal.today()/temporal.now()without arguments useperformance.now()/Date.now()at millisecond granularity — the nanosecond field is zero. - Bundle size. Each target is ~2 MB uncompressed. For
production, serve compressed (
.wasm→ Brotli / gzip).
See also
- Node guide — shared surface, helpers, type guards.
- Queries → Parameters — typed parameter binding.
- Cookbook — scenario-based recipes.
- Data Types — host-value mapping.
- Limitations — persistence caveat.
- Troubleshooting.