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.
How The App Is Structured
The repository has two main pieces:
spacetimedb/src/lib.rs: backend schema and reducerssrc/lib/*plussrc/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.).
The Event Table Pattern
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.
Diagram: Event Table Lifecycle
Reducers Let The Backend Stay In One Language
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.
Subscriptions Stay Close To The Data Model
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_eventto 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.
Diagram: Subscription Fan-Out
Media Pipeline In This Demo
The media path is intentionally straightforward:
- Audio:
AudioWorkletcaptures float samples, resamples to configured rate, encodes PCM16LE, sends to peers viasend_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.
What Is Simpler Than A SQL Relay Stack
After building this and reading the PostgreSQL version, the differences I would call out are:
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.
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.
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.
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.
What Is Not Simpler
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.
Performance Snapshot
Bandwidth
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.
Reproduce The Demo
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:
- PlanetScale: Video Conferencing with Postgres
- SpacetimeDB implementation repo: SpaceChatDB
- SpacetimeDB docs: reducers, subscriptions, event tables
