Welcome back, intrepid developer! In our previous chapters, we’ve explored the foundational concepts of SpaceTimeDB, from setting up your development environment to designing schemas and writing server-side logic using reducers. We’ve seen how SpaceTimeDB acts as a unified backend, combining a database with application logic.

Now, it’s time to unveil the “magic” that makes SpaceTimeDB truly shine: its real-time capabilities. This chapter will pull back the curtain on how client applications stay perfectly synchronized with your SpaceTimeDB instance, receiving instant updates as data changes. We’ll explore the core mechanisms of client synchronization, event propagation, and how to build responsive, collaborative experiences.

By the end of this chapter, you’ll understand:

  • How SpaceTimeDB clients establish and maintain real-time connections.
  • The power of “subscriptions” to declaratively express interest in data.
  • How SpaceTimeDB efficiently propagates changes and events to connected clients.
  • How to integrate a basic SpaceTimeDB client into a web application.
  • The fundamental patterns for building real-time features like live dashboards or multiplayer game elements.

Ready to make your applications come alive with real-time updates? Let’s dive in!

Core Concepts: The Pulse of Real-time

Traditional web applications often rely on a “request-response” model. A client asks for data, the server responds, and then the connection closes. To get updates, the client has to ask again (polling) or use complex, separate technologies like WebSockets and pub/sub systems. SpaceTimeDB elegantly solves this by integrating real-time synchronization directly into its core.

Client-Server Communication: Always Connected

At the heart of SpaceTimeDB’s real-time capabilities is a persistent, bidirectional communication channel between the client and the SpaceTimeDB instance. This channel is typically established using WebSockets.

When a client connects, it doesn’t just make a one-off request; it opens a continuous pipeline. This pipeline allows the client to:

  1. Send requests: Call reducers, execute queries.
  2. Receive updates: Get notified instantly when data it cares about changes on the server.

Think of it like a phone call versus sending a letter. With a phone call, once connected, you can have a continuous conversation, sending and receiving information in real-time.

The Power of Subscriptions

How does SpaceTimeDB know what data a client “cares about”? This is where subscriptions come into play. A subscription is a declarative instruction from the client to SpaceTimeDB, saying, “Hey, I’m interested in all data from TableA,” or “I want to know about all users with status = 'online'.”

Once subscribed, SpaceTimeDB continuously monitors the requested data. Any time a relevant change occurs (an insert, update, or delete), SpaceTimeDB automatically pushes that change to the subscribing client.

This is a stark contrast to traditional database queries, which are “point-in-time” snapshots. A subscription is a “live query” that continuously updates its results.

Why Subscriptions are Smart:

  • Efficiency: Clients only receive data they explicitly ask for, reducing network traffic.
  • Simplicity: Developers don’t need to write complex polling logic or manage manual WebSocket messages. SpaceTimeDB handles the “when to send” and “what to send.”
  • Consistency: All subscribed clients see the same, consistent state as it evolves, thanks to SpaceTimeDB’s deterministic nature.

How SpaceTimeDB Propagates Events

When a reducer executes and modifies the database, SpaceTimeDB doesn’t just update its internal state; it also generates a stream of events. These events describe what changed (e.g., “row inserted into users table,” “value updated in messages table”).

SpaceTimeDB then intelligently compares these changes against all active client subscriptions. If a change affects data that a client is subscribed to, SpaceTimeDB serializes the relevant event and pushes it down the client’s WebSocket connection.

This process is incredibly fast and efficient, designed to deliver updates with minimal latency. It’s the engine that powers real-time collaboration and dynamic user interfaces.

Deterministic State and Event Sourcing

A key architectural principle of SpaceTimeDB (which we touched upon in previous chapters) is its foundation in deterministic event sourcing. Every change to the database is a result of a reducer executing based on an incoming client call. These reducer calls are logged as a sequence of events.

Because reducers are deterministic, applying the same sequence of events to an initial state will always result in the same final state. This property is crucial for:

  • Consistency: Ensuring all clients, when caught up, see the identical database state.
  • Replication: Easily replicating the database by replaying event logs.
  • Debugging: Understanding exactly how a state was reached.

When clients connect, they often receive an initial “snapshot” of the subscribed data and then continuously receive new events to keep their local view up-to-date.

Visualizing the Real-time Flow

Let’s look at a simplified diagram of how a client interaction leads to real-time updates for other clients:

flowchart LR Client_A[Client A] -->|\1| SpaceTimeDB["SpaceTimeDB Instance"] SpaceTimeDB -->|\1| SpaceTimeDB_DB["SpaceTimeDB Database"] SpaceTimeDB_DB -->|\1| SpaceTimeDB SpaceTimeDB -->|\1| SpaceTimeDB SpaceTimeDB -->|\1| SpaceTimeDB SpaceTimeDB -->|\1| Client_A SpaceTimeDB -->|\1| Client_B[Client B] SpaceTimeDB -->|\1| Client_C[Client C] subgraph User Interaction Client_A end subgraph SpaceTimeDB Backend SpaceTimeDB SpaceTimeDB_DB end subgraph Real-time Updates Client_B Client_C end

Explanation of the flow:

  1. Client A Calls Reducer: Client A interacts with the application, which triggers a call to a SpaceTimeDB reducer (e.g., create_user).
  2. SpaceTimeDB Executes Reducer: The SpaceTimeDB instance receives the call and executes the create_user reducer.
  3. State Change: The reducer modifies the SpaceTimeDB database, changing its state.
  4. Generates Events: SpaceTimeDB internally records this state change as an event (e.g., a new user row was inserted).
  5. Checks Subscriptions: SpaceTimeDB then checks which connected clients have subscriptions that would be affected by this new event.
  6. Propagates Events: For every affected client (including Client A, Client B, Client C in this example), SpaceTimeDB pushes the relevant event (the new user data) down their WebSocket connection. All clients see the update in real-time!

Step-by-Step Implementation: Connecting a Web Client

Let’s put these concepts into practice. We’ll set up a simple HTML page with JavaScript to connect to our SpaceTimeDB instance, subscribe to a table, and react to real-time updates.

Prerequisites:

  • You have SpaceTimeDB CLI installed and a basic project initialized (from Chapter 2).
  • You have a SpaceTimeDB module running with at least one table and a reducer. For this example, let’s assume we have a users table and a create_user reducer, similar to what we might have built in Chapter 4.

Example users table schema (from Chapter 4, for reference):

// In your module's lib.rs or a separate schema file
#[spacetimedb(table)]
pub struct User {
    #[primarykey]
    #[autoinc]
    pub id: u64,
    pub username: String,
    pub created_at: u64,
}

#[spacetimedb(reducer)]
pub fn create_user(ctx: ReducerContext, username: String) -> Result<(), String> {
    if username.is_empty() {
        return Err("Username cannot be empty".to_string());
    }
    let now = ctx.timestamp;
    User::insert(User { id: 0, username, created_at: now });
    Ok(())
}

Make sure your SpaceTimeDB instance is running:

spacetime start
spacetime deploy

This will typically run on ws://localhost:3000 by default.

Step 1: Create a Simple HTML File

Let’s create an index.html file that will host our JavaScript client.

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SpaceTimeDB Real-time Users</title>
    <style>
        body { font-family: sans-serif; margin: 20px; }
        #user-list { border: 1px solid #ccc; padding: 10px; min-height: 100px; margin-top: 20px; }
        .user-item { margin-bottom: 5px; padding: 5px; background-color: #f9f9f9; border-radius: 3px; }
        input[type="text"] { padding: 8px; margin-right: 10px; border: 1px solid #ddd; }
        button { padding: 8px 15px; background-color: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; }
        button:hover { background-color: #0056b3; }
    </style>
</head>
<body>
    <h1>SpaceTimeDB User List</h1>

    <div>
        <input type="text" id="username-input" placeholder="Enter new username">
        <button id="add-user-button">Add User</button>
    </div>

    <h2>Current Users:</h2>
    <div id="user-list">
        <!-- Users will appear here in real-time -->
        <p>Connecting to SpaceTimeDB...</p>
    </div>

    <!-- Include the SpacetimeDB Client Library -->
    <script src="https://unpkg.com/@clockworklabs/[email protected]/dist/index.js"></script>
    <script src="app.js"></script>
</body>
</html>

Explanation:

  • We’re including the spacetimedb-client library directly from unpkg. This is convenient for quick examples. For production, you’d typically install it via npm (npm install @clockworklabs/spacetimedb-client) and bundle it with a tool like Webpack or Vite. We’re using v2.0.0 as of 2026-03-14, which is the latest stable major release.
  • We have a simple input and button to add users, and a div to display the list of users.
  • The app.js file is where our SpaceTimeDB client logic will reside.

Step 2: Write the JavaScript Client Logic

Now, create public/app.js and add the following code step-by-step.

Part A: Connect to SpaceTimeDB

// public/app.js

// 1. Import the SpacetimeDBClient
// If using module bundler: import { SpacetimeDBClient } from '@clockworklabs/spacetimedb-client';
// Since we're using a script tag, it's globally available as SpacetimeDBClient.

const SPACETIMEDB_URI = 'ws://localhost:3000'; // Your SpaceTimeDB instance URI
const DB_NAME = 'your_project_name'; // Replace with your actual project name from `spacetime.toml`

// Create a new client instance
const client = new SpacetimeDBClient(SPACETIMEDB_URI, DB_NAME);

// Get references to DOM elements
const userListDiv = document.getElementById('user-list');
const usernameInput = document.getElementById('username-input');
const addUserButton = document.getElementById('add-user-button');

// Store users locally
let users = {}; // Object to store users by ID for easy lookup and update

// 2. Handle connection status
client.onConnect(() => {
    console.log('Connected to SpaceTimeDB!');
    userListDiv.innerHTML = '<p>Connected. Subscribing to users...</p>';
    // We'll add subscription logic here next
});

client.onDisconnect(() => {
    console.log('Disconnected from SpaceTimeDB.');
    userListDiv.innerHTML = '<p>Disconnected from SpaceTimeDB. Please refresh.</p>';
});

client.onReconnect(() => {
    console.log('Reconnected to SpaceTimeDB.');
    userListDiv.innerHTML = '<p>Reconnected. Fetching latest data...</p>';
});

client.onError((error) => {
    console.error('SpaceTimeDB Client Error:', error);
    userListDiv.innerHTML = `<p style="color:red;">Error: ${error.message}. Check console for details.</p>`;
});

// 3. Connect to the database
client.connect();

Explanation:

  • We initialize SpacetimeDBClient with the WebSocket URI and your project’s database name (found in spacetime.toml). Make sure to replace your_project_name.
  • We set up event listeners for onConnect, onDisconnect, onReconnect, and onError to provide feedback on the connection status.
  • Finally, client.connect() initiates the WebSocket connection.

Part B: Subscribe to the users Table

Now, let’s add the subscription logic to app.js inside the onConnect callback.

// ... (previous code) ...

// Store users locally
let users = {}; // Object to store users by ID for easy lookup and update

// Function to render the user list
function renderUserList() {
    userListDiv.innerHTML = ''; // Clear existing list
    const userArray = Object.values(users).sort((a, b) => a.created_at - b.created_at); // Sort by creation time

    if (userArray.length === 0) {
        userListDiv.innerHTML = '<p>No users yet. Add one!</p>';
        return;
    }

    userArray.forEach(user => {
        const userItem = document.createElement('div');
        userItem.className = 'user-item';
        userItem.textContent = `ID: ${user.id}, Username: ${user.username}, Created: ${new Date(Number(user.created_at)).toLocaleString()}`;
        userListDiv.appendChild(userItem);
    });
}


client.onConnect(() => {
    console.log('Connected to SpaceTimeDB!');
    userListDiv.innerHTML = '<p>Connected. Subscribing to users...</p>';

    // 4. Subscribe to the 'users' table
    // The subscribe method takes the table name as a string.
    client.subscribe(['User']); // 'User' is the name of our table in Rust

    // 5. Listen for table updates
    // These events fire whenever a row is inserted, updated, or deleted in the 'User' table
    client.on('User:onInsert', (userRow, reducerCtx) => {
        console.log('User inserted:', userRow);
        users[userRow.id] = userRow; // Add to local state
        renderUserList();
    });

    client.on('User:onUpdate', (oldUserRow, newUserRow, reducerCtx) => {
        console.log('User updated:', oldUserRow, '->', newUserRow);
        users[newUserRow.id] = newUserRow; // Update local state
        renderUserList();
    });

    client.on('User:onDelete', (userRow, reducerCtx) => {
        console.log('User deleted:', userRow);
        delete users[userRow.id]; // Remove from local state
        renderUserList();
    });

    // 6. Initial data load: After subscription, the client will receive all existing data.
    // The 'User:onInitialQueryResult' event fires once with all current rows.
    client.on('User:onInitialQueryResult', (initialUsers) => {
        console.log('Initial user query result:', initialUsers);
        users = initialUsers.reduce((acc, user) => {
            acc[user.id] = user;
            return acc;
        }, {});
        renderUserList();
    });
});

// ... (rest of the code) ...

Explanation:

  • client.subscribe(['User']): This is the crucial line! It tells SpaceTimeDB we want all data from the User table. Note that the table name matches the Rust struct name.
  • client.on('User:onInsert', ...): This event listener fires whenever a new User row is inserted into the database by a reducer.
  • client.on('User:onUpdate', ...): This listener fires when an existing User row is modified.
  • client.on('User:onDelete', ...): This listener fires when a User row is removed.
  • client.on('User:onInitialQueryResult', ...): When you first subscribe, SpaceTimeDB sends all existing data for that table. This event handles that initial batch. We populate our local users object and then render.
  • renderUserList(): A helper function to take our users object and display it in the HTML.

Part C: Call a Reducer from the Client

Finally, let’s add the logic to call our create_user reducer when the “Add User” button is clicked.

// ... (previous code including onConnect, onInsert, etc.) ...

// 7. Call the create_user reducer when the button is clicked
addUserButton.addEventListener('click', async () => {
    const username = usernameInput.value.trim();
    if (username) {
        try {
            console.log(`Calling create_user reducer with username: ${username}`);
            // The callReducer method takes the reducer name and its arguments
            await client.callReducer('create_user', username);
            usernameInput.value = ''; // Clear input after successful call
        } catch (error) {
            console.error('Error calling create_user reducer:', error);
            alert(`Failed to add user: ${error.message}`);
        }
    } else {
        alert('Please enter a username!');
    }
});

// 8. Connect to the database
client.connect();

Explanation:

  • We add an event listener to the addUserButton.
  • Inside the listener, client.callReducer('create_user', username) sends a request to SpaceTimeDB to execute our create_user reducer with the provided username.
  • Since the reducer modifies the User table, SpaceTimeDB will automatically detect this change and push an onInsert event to all subscribed clients, including our own. This will trigger our onInsert listener and update the UI in real-time!

Step 3: Test Your Real-time Application

  1. Ensure SpaceTimeDB is running:
    spacetime start
    spacetime deploy
    
    Verify it’s running on localhost:3000.
  2. Open public/index.html in your browser. You can simply drag and drop the file into your browser, or serve it with a simple local web server (e.g., Python’s http.server or live-server npm package).
    # From your project root, assuming public/index.html and public/app.js
    cd public
    python3 -m http.server 8000
    # Then open http://localhost:8000 in your browser
    
  3. Observe:
    • You should see “Connected to SpaceTimeDB…” and then “No users yet. Add one!”
    • Open your browser’s developer console (F12) to see the console.log messages.
    • Type a username into the input field and click “Add User”.
    • The user should appear instantly in the “Current Users” list!
    • Crucially, open a second browser tab or window to http://localhost:8000. When you add a user in one tab, it should appear instantly in the other tab as well, without refreshing! This is the real-time magic in action.

Mini-Challenge: Enhance User Display

You’ve seen the basic real-time update. Now, let’s make it a bit more interactive.

Challenge: Modify the renderUserList function and the user-item styling so that:

  1. Each user item includes a “Delete” button.
  2. Clicking the “Delete” button calls a new SpaceTimeDB reducer named delete_user that takes a user_id and removes the corresponding user from the User table.
  3. Observe that deleting a user in one browser tab instantly removes it from all other connected tabs.

Hint:

  • You’ll need to define a new delete_user reducer in your Rust module (lib.rs). It should take user_id: u64 and use User::delete(user_id) to remove the row. Remember to handle potential errors (e.g., user not found).
  • In renderUserList, create the button element, attach an onclick event listener that calls client.callReducer('delete_user', userId), and append it to the userItem.
  • Remember to handle the User:onDelete event in your JavaScript client to update the users object and re-render.

Common Pitfalls & Troubleshooting

  1. Incorrect SpaceTimeDB URI or DB Name:
    • Symptom: Client shows “Disconnected” or “Error: WebSocket connection failed.”
    • Fix: Double-check SPACETIMEDB_URI (default ws://localhost:3000) and DB_NAME in app.js. The DB_NAME must match the name field in your spacetime.toml file.
  2. Table Name Mismatch:
    • Symptom: Client connects but doesn’t receive any data or updates for subscribed tables.
    • Fix: Ensure the table name passed to client.subscribe(['TableName']) exactly matches the Rust struct name defined with #[spacetimedb(table)] (e.g., User not users). Case sensitivity matters!
  3. Reducer Logic Errors:
    • Symptom: Reducer calls complete without error, but no data appears, or incorrect data appears.
    • Fix: Check your Rust reducer logic (lib.rs). Does it correctly insert, update, or delete the intended data? Use console.log on the client side and spacetime log on the server side to trace reducer execution and database changes.
  4. No client.connect():
    • Symptom: Nothing happens, no connection attempts.
    • Fix: Ensure client.connect() is called at the end of your app.js file.
  5. Running Client Before SpaceTimeDB:
    • Symptom: WebSocket connection errors.
    • Fix: Always ensure your SpaceTimeDB instance is running (spacetime start and spacetime deploy) before opening your client application.

Summary

Congratulations! You’ve just built your first truly real-time application using SpaceTimeDB. You’ve seen how SpaceTimeDB seamlessly integrates database management, backend logic, and real-time synchronization into a single, powerful platform.

Here are the key takeaways from this chapter:

  • SpaceTimeDB uses WebSockets for persistent, bidirectional client-server communication.
  • Subscriptions are the declarative way for clients to express interest in specific data, enabling SpaceTimeDB to push relevant updates.
  • SpaceTimeDB generates events for every database change, which are then propagated to subscribed clients.
  • The deterministic event-sourced architecture ensures consistency across all connected clients.
  • The @clockworklabs/spacetimedb-client library provides a straightforward API for connecting, subscribing, and calling reducers from web applications.

You’re now equipped to start building dynamic, collaborative, and highly responsive applications that leverage SpaceTimeDB’s real-time capabilities.

What’s Next? In the next chapter, we’ll dive deeper into more advanced subscription patterns, including how to filter and query data efficiently on the client side, and explore how to structure more complex multiplayer or collaborative application patterns.

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.