SpacetimeDB: The Database That Runs Your Server
SpacetimeDB is a relational database that also runs your server. You define tables and write functions (called reducers) that modify them, then upload the whole thing as a single module. Clients connect straight to the database, call reducers to trigger logic, and subscribe to tables for automatic real-time sync. No separate web server. No WebSocket layer. No ORM. The database is the backend.
This post walks through three business use cases with concrete code examples in both TypeScript and Rust, then compares the traditional stack against SpacetimeDB on the dimensions that matter for production systems.
The Traditional Stack vs SpacetimeDB
Consider a fleet monitoring dashboard that ingests GPS telemetry from IoT devices and displays live positions to operations staff. The traditional architecture looks something like this:
graph TD
A[IoT Devices] -->|HTTP/MQTT| B[Ingestion Service]
B --> C[PostgreSQL]
C --> D[WebSocket Server]
D --> E[React Dashboard]
B --> F[Redis Cache]
F --> D
That is four or five services to deploy, monitor, and scale independently. Each one has its own configuration, its own failure modes, and its own deployment pipeline. Now here is the same system on SpacetimeDB:
graph TD
A[IoT Devices] -->|WebSocket| B[SpacetimeDB]
B --> C[React Dashboard]
The ingestion logic, business rules, and real-time push all live inside the database. Here is what that looks like in code.
Traditional Approach: Node.js + PostgreSQL
The ingestion endpoint receives GPS pings, validates them, and writes to the database:
// ingestion-service/src/routes/telemetry.ts
import { Router } from 'express';
import { pool } from '../db';
const router = Router();
router.post('/telemetry', async (req, res) => {
const { device_id, lat, lng, battery } = req.body;
if (typeof lat !== 'number' || typeof lng !== 'number') {
return res.status(400).json({ error: 'Invalid coordinates' });
}
await pool.query(
'INSERT INTO telemetry (device_id, lat, lng, battery, recorded_at) VALUES ($1, $2, $3, $4, NOW())',
[device_id, lat, lng, battery]
);
// Publish to Redis for WebSocket server to broadcast
await redis.publish('telemetry', JSON.stringify({ device_id, lat, lng, battery }));
res.status(204).end();
});
The WebSocket server subscribes to Redis and pushes updates to connected clients:
// websocket-server/src/index.ts
import { WebSocketServer } from 'ws';
import { createClient } from 'redis';
const redis = createClient();
await redis.connect();
const sub = redis.duplicate();
await sub.subscribe('telemetry', (message) => {
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
The React client connects, subscribes, and renders:
// dashboard/src/App.tsx
const [positions, setPositions] = useState<Map<string, Position>>(new Map());
useEffect(() => {
const ws = new WebSocket('wss://ws.example.com');
ws.onmessage = (event) => {
const { device_id, lat, lng } = JSON.parse(event.data);
setPositions(prev => new Map(prev).set(device_id, { lat, lng }));
};
return () => ws.close();
}, []);
That is three files across three services, with manual JSON serialization, manual WebSocket routing, and manual state management on the client. The ingestion service, the WebSocket server, and the database all need to stay in sync on schema changes. Adding a field like speed means updating the database migration, the ingestion route, the Redis publish payload, the WebSocket broadcast, and the client state type.
SpacetimeDB Approach: Single Module
The same system as one TypeScript module:
// spacetimedb/src/index.ts
import { schema, t, table, SenderError } from 'spacetimedb/server';
const Device = table(
{ name: 'device', public: true },
{
device_id: t.string().primaryKey(),
lat: t.float64(),
lng: t.float64(),
battery: t.float64(),
last_seen: t.timestamp(),
}
);
const spacetimedb = schema(Device);
spacetimedb.reducer('report_position', {
device_id: t.string(),
lat: t.float64(),
lng: t.float64(),
battery: t.float64(),
}, (ctx, { device_id, lat, lng, battery }) => {
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
throw new SenderError('Invalid coordinates');
}
const existing = ctx.db.device.device_id.find(device_id);
if (existing) {
ctx.db.device.device_id.update({ ...existing, lat, lng, battery, last_seen: ctx.timestamp });
} else {
ctx.db.device.insert({ device_id, lat, lng, battery, last_seen: ctx.timestamp });
}
});
The React client:
// dashboard/src/App.tsx
import { useTable, useReducer } from 'spacetimedb/react';
import { tables, reducers } from './module_bindings';
function App() {
const [devices] = useTable(tables.device);
const reportPosition = useReducer(reducers.reportPosition);
return (
<div>
{devices.map(device => (
<div key={device.device_id}>
{device.device_id}: ({device.lat}, {device.lng})
</div>
))}
</div>
);
}
There is no WebSocket server to deploy. No Redis pub/sub to configure. No JSON serialization. The useTable hook subscribes to the device table and re-renders the component whenever rows change. The reducer validates and writes in one transaction. The client receives typed updates automatically.
That is the core SpacetimeDB pattern: define tables, write reducers, subscribe on the client. The plumbing that usually connects these pieces is handled by the database itself.
Use Case 1: Real-Time Fleet Dashboard
The fleet monitoring example above extends naturally to a full operations dashboard. Add a table for alerts:
const Alert = table(
{ name: 'alert', public: true },
{
id: t.identity().primaryKey(),
device_id: t.string(),
severity: t.string(), // "warning", "critical"
message: t.string(),
created_at: t.timestamp(),
acknowledged: t.bool(),
}
);
spacetimedb.reducer('check_battery', { device_id: t.string() }, (ctx, { device_id }) => {
const device = ctx.db.device.device_id.find(device_id);
if (!device) throw new SenderError('Unknown device');
if (device.battery < 20) {
ctx.db.alert.insert({
device_id,
severity: device.battery < 10 ? 'critical' : 'warning',
message: `Battery at ${device.battery}%`,
created_at: ctx.timestamp,
acknowledged: false,
});
}
});
On the client, subscribe only to the alerts table for a specific device:
const [alerts] = useTable(tables.alert);
const criticalAlerts = alerts.filter(a => a.severity === 'critical' && !a.acknowledged);
The dashboard gets live updates as devices report positions and battery thresholds trigger alerts. No polling. No separate alerting service. The business logic that decides when to create an alert runs inside the database, in the same transaction that holds the device state.
Use Case 2: Collaborative Inventory Management
A warehouse inventory system where multiple operators scan items and stock levels update instantly across all terminals. This is where SpacetimeDB’s Rust implementation shows its strength for enforcing business rules server-side.
use spacetimedb::{table, reducer, ReducerContext, Timestamp, TableType};
#[table(name = item, public)]
pub struct Item {
#[primary_key]
sku: String,
name: String,
quantity: i32,
warehouse: String,
last_updated: Timestamp,
}
#[table(name = stock_log, public)]
pub struct StockLog {
#[primary_key]
id: u64,
sku: String,
change: i32,
operator: String,
timestamp: Timestamp,
}
#[reducer]
pub fn adjust_stock(ctx: ReducerContext, sku: String, delta: i32, operator: String) -> Result<(), String> {
let item = ctx.db.item().sku().find(&sku)
.ok_or_else(|| format!("SKU {} not found", sku))?;
let new_qty = item.quantity + delta;
if new_qty < 0 {
return Err(format!("Insufficient stock for {}: have {}, need {}", sku, item.quantity, -delta));
}
ctx.db.item().sku().update(Item {
quantity: new_qty,
last_updated: ctx.timestamp,
..item
});
ctx.db.stock_log().insert(StockLog {
id: ctx.db.stock_log().count() + 1,
sku,
change: delta,
operator,
timestamp: ctx.timestamp,
});
Ok(())
}
The client subscribes to the item table and calls the reducer when an operator scans a barcode:
const [items] = useTable(tables.item);
const adjustStock = useReducer(reducers.adjustStock);
const handleScan = (sku: string, delta: number) => {
adjustStock({ sku, delta, operator: currentUser.name });
};
When Operator A adjusts a SKU in Warehouse 1, Operator B in Warehouse 2 sees the updated quantity immediately. The adjust_stock reducer enforces the business rule (no negative stock) inside the database transaction, so both operators always see consistent state. There is no race condition window where two operators could oversell the same item.
Use Case 3: Live Financial Ticker
A market data dashboard where prices update in real time and clients subscribe only to the instruments they are watching. This demonstrates SpacetimeDB’s subscription model for selective data delivery.
const Quote = table(
{ name: 'quote', public: true },
{
symbol: t.string().primaryKey(),
price: t.float64(),
change: t.float64(),
volume: t.int64(),
updated_at: t.timestamp(),
}
);
const PriceAlert = table(
{ name: 'price_alert', public: true },
{
id: t.identity().primaryKey(),
symbol: t.string(),
threshold: t.float64(),
direction: t.string(), // "above" or "below"
triggered: t.bool(),
}
);
spacetimedb.reducer('ingest_quote', {
symbol: t.string(),
price: t.float64(),
change: t.float64(),
volume: t.int64(),
}, (ctx, { symbol, price, change, volume }) => {
ctx.db.quote.symbol.update({
symbol, price, change, volume, updated_at: ctx.timestamp,
});
// Check active alerts for this symbol
for (const alert of ctx.db.price_alert.symbol.filter(symbol)) {
if (alert.triggered) continue;
const shouldTrigger =
(alert.direction === 'above' && price >= alert.threshold) ||
(alert.direction === 'below' && price <= alert.threshold);
if (shouldTrigger) {
ctx.db.price_alert.id.update({ ...alert, triggered: true });
}
}
});
spacetimedb.reducer('set_alert', {
symbol: t.string(),
threshold: t.float64(),
direction: t.string(),
}, (ctx, { symbol, threshold, direction }) => {
ctx.db.price_alert.insert({
symbol, threshold, direction, triggered: false,
});
});
The client subscribes to a filtered set of quotes:
// Subscribe only to watchlist symbols
const [quotes] = useTable(tables.quote);
const [alerts] = useTable(tables.price_alert);
const watchlist = quotes.filter(q => userWatchlist.includes(q.symbol));
const triggeredAlerts = alerts.filter(a => a.triggered);
SpacetimeDB evaluates subscriptions server-side and pushes only matching rows to each client. A client watching 10 symbols receives updates for 10 rows, not the full market feed. The price alert logic runs inside the same transaction as the quote ingestion, so there is no window where a price moves and the alert is not evaluated.
Traditional Stack vs SpacetimeDB: Comparison
| Dimension | Traditional Stack | SpacetimeDB |
|---|---|---|
| Infrastructure | Separate services for API, WebSocket, database, cache | Single database module |
| Real-time Sync | Manual: WebSocket server, pub/sub, client state management | Automatic: subscribe to tables, get live updates |
| Type Safety | Client and server types diverge; schema drift is common | One type system; module schema generates typed client bindings |
| Deployment | Multiple services, containers, orchestration | Single spacetime publish command |
| Operational Cost | Scale each service independently; monitor multiple health checks | Scale the database; one thing to monitor |
| Development Speed | Faster with existing REST patterns and mature libraries | Faster once the paradigm clicks; steeper initial learning curve |
| Ecosystem | Mature: every language, framework, and tool works with REST/WebSocket | Growing: TypeScript, Rust, C#, C++ SDKs; fewer third-party integrations |
| Batch/Analytics | Natural fit with existing data warehouse tools | Designed for real-time OLTP; not optimised for analytical queries |
The traditional stack wins on ecosystem breadth and existing tooling. SpacetimeDB wins when real-time state synchronization is the primary product requirement and you want to eliminate the infrastructure that usually delivers it.
Practical Takeaways
Use SpacetimeDB when the application’s core value is keeping multiple clients in sync with shared state. Multiplayer games are the canonical example, but the same pattern applies to collaborative tools, live dashboards, IoT monitoring, and any system where stale data is a bug. Small teams benefit most because SpacetimeDB replaces several services you would otherwise need to build, deploy, and maintain.
Stick with the traditional stack when the application is primarily request-response (CRUD APIs, form submissions, report generation), when you have existing infrastructure and expertise in a particular stack, or when the real-time requirements are modest enough for polling or simple WebSocket broadcasts. The REST ecosystem is deeper, the hiring pool is larger, and the debugging tooling is more mature.
The pragmatic pattern is to evaluate SpacetimeDB for new projects where real-time is a first-class requirement, not an afterthought. For existing systems, the integration path is harder because SpacetimeDB replaces the server layer rather than sitting alongside it. The most natural migration is the real-time component: extract the WebSocket server and its state management into a SpacetimeDB module, and let the REST API continue handling everything else.
The Bottom Line
The backend infrastructure that delivers real-time updates - WebSocket servers, pub/sub brokers, cache layers, client-side state reconciliation - is plumbing. It is necessary plumbing, but it is not your product. SpacetimeDB bets that this plumbing belongs inside the database, not between the database and your application.
That bet pays off when state synchronization is the hard part of your system. For a fleet dashboard where every second of latency matters, for a warehouse system where two operators must never see inconsistent stock, for a market data feed where alerts must fire in the same transaction as price updates, collapsing the architecture eliminates entire categories of bugs and operational overhead.
The trade-off is real. SpacetimeDB is younger, has a smaller ecosystem, and demands that you unlearn the REST pattern. But for the class of applications where “everything is real-time” is not a buzzword but an architectural requirement, the simplified stack is a genuine advantage.
The model matters more than the infrastructure, but when the infrastructure gets out of the way, you notice.
References and Documentation
- SpacetimeDB Documentation - Getting started, core concepts, and tutorials
- SpacetimeDB GitHub - Source code, issues, and releases
- Core Concepts - Tables, reducers, subscriptions, and authentication
- TypeScript SDK - Client library API reference
- Rust SDK - Client library API reference
- CLI Reference - spacetimedb CLI commands
- SQL Reference - Query syntax and operators