Shortly after we shared what we called the first video call over a database, PlanetScale published a great follow-up where they built the same idea on PostgreSQL and documented each tradeoff in detail: Video Conferencing with Postgres.

This post explains how the SpacetimeDB version works, why the implementation ended up compact, and where the patterns differ from a SQL-plus-relay architecture.

The short version is simple. This is still a demo, and WebRTC is still the right tool for production calling. But as a way to understand SpacetimeDB, this project is a very practical example.

The repository has two main pieces:

  • spacetimedb/src/lib.rs: backend schema and reducers
  • src/lib/* plus src/routes/+page.svelte: client connection, subscriptions, and media runtime

The backend keeps durable state in regular tables (user, call_session, call_member, chat_message, media_settings) and uses event tables for transient signaling and media (call_invite_event, join_request_event, audio_frame_event, video_frame_event, etc.).

SpacetimeDB event tables are designed for data that should be delivered to subscribers but not stored as ongoing table state.

This is exactly what media frames and one-off call prompts need.

#[spacetimedb::table(accessor = audio_frame_event, public, event)]
#[derive(Clone)]
pub struct AudioFrameEvent {
    pub session_id: Uuid,
    pub from: Identity,
    pub to: Identity,
    pub seq: u32,
    pub sample_rate: u32,
    pub channels: u8,
    pub rms: f32,
    pub pcm16le: Vec<u8>,
} 

In this project, each audio or video frame is inserted by a reducer, broadcast to subscribers, and not accumulated as durable row state. That means we did not need a separate cleanup loop to prune old frames.

By contrast, in the PostgreSQL implementation, frame rows accumulate in normal tables and are pruned with periodic deletes. That approach is valid and worked well, but it is an extra moving part.

event

SpacetimeDB reducers are transactional functions that mutate tables. They are the only mutation path, and clients read changes through subscriptions.

In this repo, call setup and validation happen directly in Rust, inside reducers. For example, request_call validates availability, creates a session, inserts caller membership, writes a private pending invite row, and emits an invite event.

#[spacetimedb::reducer]
pub fn request_call(ctx: &ReducerContext, target: Identity, call_type: CallType) -> Result<(), String> {
    let caller = ctx.sender();
    if caller == target {
        return Err("Cannot call yourself".to_string());
    }
    if ctx.db.user().identity().find(&target).is_none() {
        return Err("Target is not online".to_string());
    }

    // Create session + invite event (abridged)
    // ctx.db.call_session().insert(...)
    // ctx.db.call_invite_event().insert(...)
    Ok(())
} 

This is where the the advantage of having all the logic in one place, written in a modern language, shows up most clearly. We can keep branching logic, reusable helpers, type-safe enums, and identity checks together in one place, rather than split across SQL statements and external relay code.

The media reducers follow the same pattern. send_audio_frame and send_video_frame enforce membership, call state, call type, and payload limits before publishing an event.

On the client, subscriptions are declared once during connection setup using query builders, then callbacks update local stores or media playback handlers.

Typed subscription syntax in a TypeScript client has the following shape. The absence of a where clause results in a subscription to all rows. See the SpacetimeDB docs on subscriptions for more info.

const subs = [
    tables.user,
    tables.chat_message,
    tables.call_session,
    tables.call_member,

    tables.audio_frame_event.where(r => r.to.eq(identity)),
    tables.video_frame_event.where(r => r.to.eq(identity)),
    tables.call_invite_event.where(r => r.to.eq(identity)),
    tables.join_request_event.where(r => r.to.eq(identity)),
    tables.join_response_event.where(r => r.to.eq(identity)),
    tables.join_request_resolved_event.where(r => r.to.eq(identity)),
];

conn.subscriptionBuilder().subscribe(subs); 

A few important details:

  • The durable tables feed normal UI state (users, sessions, members, chat).
  • Event table queries are identity-filtered (to = current identity) so each client receives only addressed events.
  • Extending behavior is usually additive. For example, this codebase added join_request_resolved_event to dismiss join popups for all participants; that required one new event table, one reducer insert path, one subscription, and one callback.

The implementation is still explicit, but the pieces line up directly with the schema, and that keeps it easy to evolve.

event

The media path is intentionally straightforward:

  • Audio: AudioWorklet captures float samples, resamples to configured rate, encodes PCM16LE, sends to peers via send_audio_frame
  • Video: canvas snapshots to JPEG, sends to peers via send_video_frame
  • Playback: incoming audio frames are scheduled in a small Web Audio buffer; incoming JPEG frames replace <img> tile URLs

media_settings is a singleton table initialized in init, so capture and send limits are data-driven (audio_frame_ms, video_fps, video_jpeg_quality, max frame bytes).

That makes tuning a table change, not a client redeploy.

After building this and reading the PostgreSQL version, the differences I would call out are:

  1. Transient event semantics are first-class. The event-table model maps directly to signaling and frame delivery, so there is no frame-retention cleanup path to maintain.

  2. Subscription and query APIs are typed. The client subscribes with generated query builders against known tables, and event handling is attached directly to generated table accessors.

  3. Backend logic stays in one runtime. Validation, state transitions, and event emission live in reducer code. There is no separate WebSocket relay process that duplicates business rules.

  4. Identity is already integrated. Reducers use ctx.sender() for the authenticated caller, so call authorization checks are tied to the connection principal from the start.

None of this means SQL is the wrong tool. It means SpacetimeDB gives you a different set of defaults for real-time state synchronization.

A database-centric media demo still has real limitations:

  • Mesh fan-out grows quickly as participants increase
  • JPEG frame transport is bandwidth-heavy compared with video codecs used by WebRTC
  • Browser media and jitter control still need careful handling

So the conclusion is the same one PlanetScale reached: for production-grade calling, use WebRTC.

This project is useful because it demonstrates how quickly you can stand up a synchronized, multi-user, real-time app when the database, realtime sync layer, and backend logic are unified.

Bandwidth
event

Total compute
Blue is showing the time spent in reducers and yellow the time spent in computing subscriptions and sending updates. These are total values over the sampled time interval, so at peak we were spending a total of 10ms compute time during a 15 second interval storing video/audio frames and sending them to the recipient that subscribed to them.

event

git clone https://github.com/Lethalchip/SpaceChatDB
cd SpaceChatDB
spacetime dev <db-name> 

If you want the best comparison, read both posts side by side, then inspect the code: