SpacetimeDB Subscriptions and Real-Time Updates: A Complete Technical Guide
SpacetimeDB delivers real-time data to clients through a subscription mechanism built on top of WebSocket connections, using a three-layer architecture involving client-side builders, WebSocket transport, and server-side routing.
The clockworklabs/SpacetimeDB repository implements this system as a tightly integrated stack that enables clients to subscribe to SQL queries and receive live updates as the underlying data changes. Understanding how SpacetimeDB subscriptions and real-time updates work requires examining the interaction between the SDK, the WebSocket transport layer, and the server-side routing logic.
How SpacetimeDB Subscriptions Work: The Three-Layer Architecture
The subscription system is divided into three logical layers, each with distinct responsibilities and key types.
Client-Side API Layer
The client-side API is responsible for building subscriptions, registering callbacks, and driving the WebSocket driver. The primary types include SubscriptionBuilder, SubscriptionHandle, and DbConnection. This logic resides in sdks/rust/src/subscription.rs, where the builder pattern allows developers to chain configuration methods before finalizing the subscription.
WebSocket Transport Layer
This layer handles the HTTP to WebSocket upgrade, maintains connection keep-alives through ping/pong frames, serializes and deserializes messages, and enforces protocol versions. Key types include WebSocketUpgrade, WebSocketResponse, and WebSocketStream. The implementation is located in crates/client-api/src/util/websocket.rs.
Server-Side Routing Layer
The server-side routing accepts the WebSocket upgrade, creates a ClientConnection, injects the initial identity token, and forwards subscription requests to the module host. The primary entry point is the handle_websocket function in crates/client-api/src/routes/subscribe.rs, which utilizes ClientConnection and WsVersion types to manage the connection lifecycle.
Building a Subscription with the SpacetimeDB SDK
To create a subscription, you use the subscription_builder method on a connection object, register callbacks, and specify the SQL query to monitor.
use spacetimedb::Client;
use spacetimedb::module::MyModule;
#[tokio::main]
async fn main() -> spacetimedb::Result<()> {
// Connect to the remote module (WebSocket opens lazily)
let conn = MyModule::connect("ws://localhost:8080").await?;
// Create a subscription builder and configure callbacks
let sub_handle = conn
.subscription_builder()
.on_applied(|ctx| {
println!("Subscription applied for query {}", ctx.query_set_id());
})
.on_error(|err_ctx, err| {
eprintln!("Subscription error: {err}");
})
// Register the SQL query
.subscribe("SELECT id, value FROM my_table WHERE value > 10");
// Run the event loop that processes WebSocket messages
conn.run_async().await?;
Ok(())
}
The builder pattern returns a SubscriptionBuilder that allows method chaining for configuration. The on_applied callback fires when the server acknowledges the subscription, while on_error handles query rejection or schema changes. Internally, the subscribe call creates a SubscriptionHandleImpl, registers it with a local SubscriptionManager, and queues a PendingMutation::Subscribe for transmission by the driver.
WebSocket Transport: Upgrades, Keep-Alives, and Message Ordering
The HTTP GET /subscribe/:name_or_identity endpoint is handled by handle_websocket in crates/client-api/src/routes/subscribe.rs. The process involves three critical steps:
-
Protocol negotiation: The server selects between
ws_v1::BIN_PROTOCOL,ws_v1::TEXT_PROTOCOL, orws_v2::BIN_PROTOCOL. This selection determines the wire format (Protocol::BinaryorProtocol::Text) and the version (WsVersion::V1orWsVersion::V2). -
WebSocket upgrade: The
WebSocketUpgrade::select_protocolfunction constructs aWebSocketResponsecontaining the mandatorySec-WebSocket-Acceptheader and optionalSec-WebSocket-Protocolheaders. -
Actor spawn:
tokio::spawnlaunchesws_client_actor, which owns three concurrent loops:ws_recv_loop: Reads incomingWsMessages, updates the idle timer, and forwards parsedClientMessages to the message handler.ws_send_loop: Drains a bounded outgoing queue, interleaving control frames (Ping,Close) with subscription updates.ws_idle_timer: Resets the idle deadline when messages are received; closes the connection if the deadline expires.
The ping/pong flow ensures that idle connections are terminated quickly while keeping bandwidth-constrained clients alive. Because the WebSocket driver runs on a single tokio task, all messages are strictly ordered per client, guaranteeing deterministic application of updates.
Server-Side Subscription Lifecycle
When the client sends a Subscribe message, the following lifecycle events occur:
| Event | Server Processing | Client Reaction |
|---|---|---|
| Subscribe → Server | The ClientConnection queues the subscription request, then the module host receives it via module_rx. |
The driver marks the subscription as Sent; no immediate action. |
| Server → Client: SubscribeApplied | The module host confirms the query set ID. | The client's SubscriptionManager::subscription_applied invokes the stored on_applied callback. The client now receives real-time updates for that query. |
| Server → Client: UpdateRows | Each data change is encoded as ws_v2::ServerMessage::TableRows (or V1 equivalent) inside ws_send_loop. The message is serialized with ws_encode_message. |
ws_client_message_handler extracts DataMessages. ClientConnection::handle_message deserializes the rows and updates the local cache. |
| Unsubscribe | When SubscriptionHandle::unsubscribe_then is called, SubscriptionState::unsubscribe_then queues a PendingMutation::Unsubscribe. The server acknowledges with UnsubscribeApplied. |
SubscriptionManager::unsubscribe_applied invokes the optional on_ended callback. The client stops receiving updates for that query. |
The SubscriptionManager (located in sdks/rust/src/subscription.rs) serves as the authoritative registry of all active subscriptions. It tracks the subscription's server state (Pending, Sent, Applied, Ended, Error), manages callbacks (on_applied, on_error, on_ended), and handles race conditions such as unsubscribing before the subscription is sent (see PendingUnsubscribeResult).
All subscription state lives on the client; the server only knows the query set ID and the fact that a client is subscribed. This design enables stateless reconnections—after a network drop, the client can re-issue its pending subscriptions without losing data.
Real-Time Update Flow: End-to-End
The following diagram illustrates the complete flow from client subscription to server-side data updates:
+-------------------+ +----------------------+ +-------------------+
| Client SDK | WS | SpacetimeDB Server | WS | Module Host |
| (SubscriptionBuilder) | <----> | (handle_websocket) | <----> | (apply_query) |
+-------------------+ +----------------------+ +-------------------+
1. SDK builds a Subscribe request
2. handle_websocket creates a ClientConnection
3. ClientConnection queues PendingMutation::Subscribe
4. ws_send_loop serializes to ws_v2::Subscribe -> server
5. Module host registers the query set, sends SubscribeApplied
6. SubscriptionManager fires on_applied
7. Subsequent DB changes -> ws_encode_message -> DataMessage frames
8. ws_recv_loop -> ClientConnection::handle_message -> local cache
9. Optional on_error / on_ended callbacks fire on failures or unsubscription
Because the WebSocket driver runs on a single tokio task, all messages (subscription control, data updates, pings) are strictly ordered per client, guaranteeing deterministic application of updates.
Practical Patterns for SpacetimeDB Subscriptions
The following patterns demonstrate common use cases for working with subscriptions in production applications:
| Use Case | Code Pattern |
|---|---|
| Subscribe to a single query | conn.subscription_builder().subscribe("SELECT * FROM scores") |
| Subscribe to multiple queries | builder.add_query(...).add_query(...).subscribe() (see TypedSubscriptionBuilder lines 30-46) |
| Subscribe to all tables (debug/quick-start) | builder.subscribe_to_all_tables() (lines 12-18) |
| React to data updates | The SDK automatically updates the generated RemoteTables view; read the latest rows after frame_tick() or inside a custom on_applied/on_error callback. |
| Graceful shutdown | Call `sub_handle.unsubscribe_then( |
Key Source Files and Implementation Details
Understanding the subscription architecture requires familiarity with the following source files:
| File | Implementation Details |
|---|---|
crates/client-api/src/routes/subscribe.rs |
WebSocket upgrade handling, ClientConnection creation, initial identity token injection (lines 12-31). |
crates/client-api/src/util/websocket.rs |
Low-level WebSocket handshake, protocol selection, ping/pong handling (lines 1-80). |
sdks/rust/src/subscription.rs |
SubscriptionBuilder, SubscriptionManager, state machine implementation, callback registration (lines 46-180). |
sdks/rust/src/subscription.rs (continued) |
SubscriptionHandle, TypedSubscriptionBuilder, IntoQueries helper traits (lines 180-300). |
templates/*/src/module_bindings/mod.rs |
Auto-generated bindings exposing subscription_builder() to end-users (lines 44-49). |
sdks/rust/tests/view-client/src/module_bindings/mod.rs |
Example usage in the test suite (see subscription_error_smoke_test). |
crates/client-api/src/lib.rs |
Public re-exports including ClientConnection and SubscriptionBuilder. |
Summary
- SpacetimeDB implements real-time data delivery through a subscription mechanism built on WebSocket connections, utilizing a three-layer architecture separating client API, transport, and server routing concerns.
- The client-side SDK provides a builder pattern via
SubscriptionBuilderinsdks/rust/src/subscription.rs, allowing developers to register callbacks for subscription lifecycle events (on_applied,on_error,on_ended). - The WebSocket transport layer in
crates/client-api/src/util/websocket.rshandles protocol negotiation (V1/V2), connection keep-alives via ping/pong, and strict message ordering through a singletokiotask per client. - The server-side routing in
crates/client-api/src/routes/subscribe.rsmanages theClientConnectionlifecycle, forwarding subscription requests to the module host and streaming row updates back to clients. - All subscription state resides on the client, enabling stateless reconnections where pending subscriptions can be re-issued after network interruptions without data loss.
Frequently Asked Questions
How does SpacetimeDB handle subscription reconnections after network failures?
Because all subscription state lives on the client side, SpacetimeDB enables stateless reconnections. When a network drop occurs, the client can re-establish the WebSocket connection and re-issue its pending subscriptions without losing data. The SubscriptionManager in sdks/rust/src/subscription.rs tracks the server state (Pending, Sent, Applied) and handles race conditions such as unsubscribing before the subscription is fully sent.
What is the difference between WebSocket protocol V1 and V2 in SpacetimeDB?
SpacetimeDB supports two WebSocket protocol versions negotiated during the handshake in crates/client-api/src/util/websocket.rs. V1 supports both binary (ws_v1::BIN_PROTOCOL) and text (ws_v1::TEXT_PROTOCOL) formats, while V2 (ws_v2::BIN_PROTOCOL) uses an optimized binary format for ServerMessage::TableRows and other subscription updates. The selected protocol determines the serialization method used by ws_encode_message in the transport layer.
How are subscription queries validated on the server?
When a client sends a Subscribe message, the server-side handle_websocket function in crates/client-api/src/routes/subscribe.rs creates a ClientConnection that queues the subscription request. The module host receives this via module_rx and validates the SQL query syntax and permissions. If validation fails, the server sends an error message that triggers the client's on_error callback via SubscriptionManager::subscription_error.
Can I subscribe to multiple tables with a single subscription handle?
Yes, the SDK supports subscribing to multiple queries through a single builder. Using TypedSubscriptionBuilder in sdks/rust/src/subscription.rs, you can chain multiple add_query() calls before invoking subscribe(). Alternatively, for development and debugging purposes, you can use subscribe_to_all_tables() to automatically generate subscriptions for every table in the module. All queries registered to a single builder share the same lifecycle callbacks (on_applied, on_error, on_ended).
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →