Back to Blog
technicaltutorials

"AimDB Meets the Browser: Full Dataflow Engine in WebAssembly"

February 27, 2026AimDB Team8 min read

AimDB's promise has always been one typed dataflow engine across every tier — MCU, edge, cloud. But there's been an obvious gap: the browser. Frontend teams either polled an HTTP API or consumed a raw WebSocket feed with no contract enforcement on the receiving end. The data contracts you defined in Rust stopped at the server boundary and everything after that was unvalidated JSON.

The new aimdb-wasm-adapter crate closes that gap. The full AimDB engine — buffers, typed records, producer-consumer patterns — now compiles to wasm32-unknown-unknown and runs inside the browser. Contract validation happens in Rust, inside WASM, before JavaScript ever sees the data.

In this post we'll walk through how it works, what the programming model looks like and why the design avoids the parallel type system problem that plagues most WASM integrations.

The Problem with WASM Bindings

The standard approach to "Rust in the browser" is:

  1. Write your Rust logic
  2. Expose it via wasm-bindgen
  3. Duplicate every data type in TypeScript
  4. Serialize both ways and hope they match

This works for small APIs. It falls apart for data-heavy systems because you end up maintaining two type systems that must stay in sync — Rust structs on the WASM side, TypeScript interfaces on the JS side. Every schema change is a coordinated update across both worlds.

AimDB's WASM adapter takes a different approach. The TypeScript layer never defines data types at all. Rust data contracts are the single source of truth, and the WASM boundary is where enforcement happens — not a place where types are redeclared.

How It Works

The adapter implements the same four executor traits (RuntimeAdapter, Spawn, TimeOps, Logger) from aimdb-executor that the Tokio and Embassy adapters implement. From aimdb-core's perspective, the browser is just another runtime:

TraitWASM Implementation
RuntimeAdapterPlatform identity: "wasm"
Spawnwasm_bindgen_futures::spawn_local
TimeOpsPerformance.now() + setTimeout
Loggerconsole.log / warn / error

Buffers use Rc<RefCell<…>> instead of atomics — zero overhead for the single-threaded browser environment. All three buffer types work: SPMC Ring, SingleLatest and Mailbox. The Send + Sync bounds required by the executor traits are satisfied trivially — wasm32-unknown-unknown is single-threaded by construction.

The Streamable Trait

The key design decision is how types cross the WASM boundary. Rather than building a parallel type registry in the WASM adapter, all boundary-crossable contracts implement a single trait in aimdb-data-contracts:

pub trait Streamable: SchemaType + Serialize + DeserializeOwned + Send + Sync + Clone + Debug + 'static { } impl Streamable for Temperature {} impl Streamable for Humidity {} impl Streamable for GpsLocation {}

Streamable is a capability marker. It combines schema identity (SchemaType::NAME) with the serde bounds needed for type-erased dispatch. The companion macro dispatch_streamable! routes a runtime schema name string to its concrete Rust type:

dispatch_streamable!(schema_name, |T| { // T is now the concrete type — Temperature, Humidity, etc. builder.configure::<T>(key, |reg| reg.buffer(cfg)); }) .ok_or_else(|| format!("Unknown schema: {schema_name}"))?;

This macro is the single source of truth for the schema-name → type mapping. The WASM bindings, the WebSocket bridge and any future consumer all use it. Adding a new contract to the WASM boundary is four lines:

  1. Define your struct with Serialize + Deserialize
  2. Implement SchemaType with a unique NAME
  3. impl Streamable for MyType {}
  4. Add one match arm to dispatch_streamable!

Every consumer picks up the new type automatically. No codegen. No build step. No TypeScript interface to keep in sync.

TypeScript API: Two-Phase Lifecycle

From JavaScript, the API is a simple two-phase pattern — configure, then operate:

import { WasmDb } from '@aimdb/wasm'; const db = new WasmDb(); // Phase 1: configure records by schema name db.configureRecord('sensors.temperature.vienna', { schemaType: 'temperature', buffer: 'SingleLatest', }); db.configureRecord('sensors.humidity.vienna', { schemaType: 'humidity', buffer: { type: 'SpmcRing', capacity: 200 }, }); // Phase 2: build, then operate await db.build(); // Validated by Rust serde inside WASM — invalid payloads throw db.set('sensors.temperature.vienna', { celsius: 22.5, timestamp: Date.now() }); const temp = db.get('sensors.temperature.vienna'); // temp.celsius is guaranteed to be a number — Rust deserialized it const unsub = db.subscribe('sensors.temperature.vienna', (value) => { console.log('New reading:', value.celsius); });

Notice what's missing: no TypeScript type definitions for Temperature or Humidity. The schemaType: 'temperature' string routes to the Rust struct at build time. When you call db.set(), the value crosses into WASM, Rust's serde deserializes it into the concrete type and any schema violation — missing field, wrong type, extra property — is caught immediately. The validated, typed value then enters the buffer. When you call db.get(), the typed value is serialized back to a JS object.

The WASM boundary is the validation layer.

Three Operational Modes

The adapter supports three modes that mirror real deployment patterns:

Mode 1: Local Only — The database runs entirely in the browser. Sources produce data from local APIs (geolocation, clipboard, user input), transforms derive new values, taps drive the UI. No server required.

Mode 2: Synchronized — A WsBridge connects to a server-side AimDB instance over WebSocket. The browser instance mirrors server records in real time. The bridge speaks the same wire protocol as aimdb-websocket-connector:

import { WsBridge } from '@aimdb/wasm'; const bridge = WsBridge.connect(db, 'wss://api.cloud.aimdb.dev/ws', { subscribeTopics: ['sensors/#'], autoReconnect: true, lateJoin: true, // request snapshots on (re)connect }); bridge.onStatusChange((status) => { // 'connecting' | 'connected' | 'disconnected' | 'reconnecting' console.log('Connection:', status); });

lateJoin: true requests the current value of every subscribed record when the connection opens. This means the UI renders immediately on connect — no waiting for the next sensor push.

Mode 3: Hybrid — Local records co-exist with synced records in the same database. You can source data locally (camera, accelerometer, user events) while simultaneously consuming server-pushed telemetry. Transforms can bridge both worlds:

db.configureRecord('local.user.location', { schemaType: 'gps_location', buffer: 'SingleLatest', }); db.configureRecord('server.weather.vienna', { schemaType: 'temperature', buffer: 'SingleLatest', }); // local.user.location is sourced from the Geolocation API // server.weather.vienna arrives via WsBridge // A transform could derive a "nearest station" record from both

React Hooks

For React applications, the adapter ships a set of hooks that handle WASM initialization, record subscription and cleanup:

import { AimDbProvider, useRecord, useSetRecord } from '@aimdb/wasm/react'; function App() { return ( <AimDbProvider config={{ records: [ { key: 'sensors.temperature.vienna', schemaType: 'temperature', buffer: 'SingleLatest' }, { key: 'sensors.humidity.vienna', schemaType: 'humidity', buffer: 'SingleLatest' }, ], bridge: { url: 'wss://api.cloud.aimdb.dev/ws', subscribeTopics: ['sensors/#'] }, }}> <Dashboard /> </AimDbProvider> ); } function Dashboard() { const temp = useRecord<Temperature>('sensors.temperature.vienna'); if (!temp) return <Skeleton />; return <span>{temp.celsius.toFixed(1)}°C</span>; }

useRecord<T>(key) subscribes to real-time updates and re-renders on every buffer push. The component receives null until the first value arrives — standard React data-fetching semantics, except the "server" is a full Rust dataflow engine running in the same browser tab.

useSetRecord<T>(key) returns a setter function for writable records:

const setTarget = useSetRecord<Setpoint>('commands.setpoint.room1'); setTarget({ target_celsius: 21.0, timestamp: Date.now() });

The AimDbProvider loads the WASM module asynchronously, builds the database and optionally connects the WebSocket bridge — all before rendering children. An optional fallback prop lets you show a loading state while initialization completes.

What the Boundary Enforces

Every value that crosses the JS ↔ WASM boundary passes through Rust serde. This means:

  • Missing fields → error before the value enters the buffer
  • Wrong types (string where f32 expected) → error
  • Unknown schema names → rejected at configureRecord() time
  • Malformed WebSocket payloads → dropped with a warning, buffer unaffected

The contract enforcement is not advisory. Invalid data does not enter the system. This is the same guarantee you get on the Tokio and Embassy runtimes — the WASM adapter doesn't relax it.

Build Integration

The adapter compiles with standard wasm-pack:

cd aimdb-wasm-adapter wasm-pack build --target web --release

The CI pipeline validates the WASM target on every push:

wasm: cargo build -p aimdb-wasm-adapter --target wasm32-unknown-unknown --features wasm-runtime wasm-test: cargo test -p aimdb-wasm-adapter --no-default-features # native-target unit tests

The wasm-runtime feature (enabled by default) pulls in wasm-bindgen, js-sys, and web-sys. Disabling it allows running unit tests on native targets without a browser — buffer logic, option parsing, and protocol handling are all testable without WASM.

The Full Picture

With the WASM adapter, AimDB's typed dataflow now spans every tier:

TierRuntimeAdapter
MCUEmbassyaimdb-embassy-adapter
Edge / CloudTokioaimdb-tokio-adapter
BrowserWASMaimdb-wasm-adapter

Same data contracts. Same buffer semantics. Same producer-consumer patterns. The runtime adapter is the only thing that changes.

A Temperature record defined once in aimdb-data-contracts can be produced on an STM32 microcontroller, synced over MQTT to an edge gateway, persisted to SQLite, served over WebSocket, consumed in a React component — and every hop validates the same Rust struct. No translation layers. No parallel type systems. No glue code.

That's the promise of a data-first architecture carried all the way to the browser.


Check out the WASM adapter source on GitHub, or explore the React hooks example to get started.

Have questions? Open an issue on GitHub or join the discussion.