Before we dive in, grab your popcorn. This post comes with a side of drama.
The Database Wars: Twitter’s Favorite Bloodsport
If you’ve spent any time on Twitter (sorry, X) in the past couple of years, you’ve probably witnessed it: the Great Database Debates. Someone posts “Just migrated from PostgreSQL to [shiny new DB]” and suddenly everyone’s an expert. Redis vs. Postgres vs. DynamoDB vs. MongoDB — it’s like watching philosophers argue about the number of angels on a pin, except the pins have uptime SLAs.
But every now and then, something different shows up. Something that makes you go “wait, the database runs my code? And it pushes updates to clients? And it’s fast?” Enter SpacetimeDB — the database that had Hacker News in a frenzy, the licensing debate crowd sharpening their pitchforks, and in February 2026… sparked the most entertaining database controversy to date.
The February 2026 “Fastest Database” Drama
Okay, so here’s where things get spicy. On February 24, 2026, SpacetimeDB dropped a benchmark video claiming their 2.0 release was the “FASTEST database in the world” — complete with dramatic music, a keynote demo showing 100,000 message inserts per second, and the now-infamous claim of being 1000x faster than your database.
The internet, being the internet, immediately went into full investigation mode.
Within 24 hours, developer Brandon Pollack published a gist titled “investigation.md” (because of course that’s what it’s called) digging into the benchmark methodology. His takeaway? “Cool Demo but…huh?” — noting that while not having TCP between the DB and app is definitely fast, being over 10x faster than in-process SQLite (which can run in memory?) raised some eyebrows. The investigationgist went semi-viral, with developers RT-ing it with things like “finally someone asked the question.”
Meanwhile, Prasad Pilla on LinkedIn (yes, LinkedIn — we’re reaching new platforms) actually ran his own head-to-head benchmark against Convex, a database he actually uses. His results? SpacetimeDB delivered 47x the throughput and 39x lower latency — still insanely impressive, but notably less than 1000x. The takeaway? “Really impressive” but maybe temper those claims just a tiny bit.
And then came the memes. Oh, the memes. “My SQL query is faster than your benchmark methodology.” “SpacetimeDB — it’s fast, we swear.” “1000x faster than PostgreSQL, if you remove PostgreSQL, the network, and the laws of physics.” Twitter’s database community had a field day.
Tyler Cloutier (SpacetimeDB’s cofounder) responded with grace, engaging with the critiques and pointing out that the benchmark was comparing a hosted DB-to-client direct path versus traditional client-to-server-to-DB architectures. Fair enough! But the drama had already spawned multiple HN threads, a few choice subtweets, and one particularly scathing take titled “Are published ANN-Benchmarks DBMS results trustworthy?” (though that was more about vector DBs, it felt adjacent).
The whole saga was a masterclass in: bold claims generate engagement, the community will verify your numbers, and Database Twitter is absolutely feral for technical drama. Will we ever know the true speed of SpacetimeDB? Probably not. Will we keep arguing about it? Absolutely.
So… What’s the Deal With SpacetimeDB?
SpacetimeDB burst onto the scene with a bold claim: it’s a “real-time backend framework and database for apps and games” that handles persistence, logic, deployment, and real-time sync in one cohesive stack.
The internet, naturally, had thoughts:
- The hype: “It processes millions of transactions at the speed of light!” “You can build a Discord clone in hours!” “Multiplayer games at scale with zero boilerplate!”
- The skepticism: “Is it though?” “What’s the catch?” “Why does the license look like that?”
Ah yes, the license. SpacetimeDB uses BSL 1.1 (Business Source License), which means it’s source-available but not exactly “open source” in the traditional sense. It converts to AGPL after 4 years, which sent the “but what about FORKERS?” crowd into a tailspin on Hacker News. People on Twitter debated whether this was a clever business move or a slap in the face to the open-source community. (Spoiler: both perspectives are valid, and the flamewars were spicy.)
Then there’s the Bitcraft connection — SpacetimeDB was built to power Bitcraft Online, an MMO that Clockwork Labs has been developing for years. The MMO launched on Steam in early 2026, and suddenly all those “but does it actually work at scale?” questions got answered in real-time. Developer Voices did a whole episode in February 2026 titled “What launching an MMO can teach us about Databases” — because nothing says “database credibility” like running a massively multiplayer online game on your own infrastructure. Some folks were impressed. Others pointed out that building a game to prove your database works is… a bold strategy, Cotton. (It worked, though — the database held up.)
But here’s the thing — regardless of the Twitter drama, the technology is genuinely interesting. And that’s what we’re actually going to talk about today.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ BROWSER (Client) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ React App (Next.js) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ page.tsx │ │providers.tsx│ │ SpacetimeDBProvider │ │ │
│ │ │ (UI) │ │ (Config) │ │ (Connection Manager)│ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ │
│ │ │ │ │ │ │
│ │ └────────────────┴──────────┬──────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼────────┐ │ │
│ │ │ WebSocket │ │ │
│ │ │ Connection │ │ │
│ │ └────────┬────────┘ │ │
│ └────────────────────────────────────┼────────────────────────────┘ │
└────────────────────────────────────────┼──────────────────────────────┘
│
│ ws://localhost:3000
▼
┌─────────────────────────────────────────────────────────────────────┐
│ SPACETIMEDB SERVER │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Database │ │
│ │ ┌────────────────┐ ┌─────────────────────────────────────┐ │ │
│ │ │ user table │ │ message table │ │ │
│ │ │ - identity PK │ │ - sender (identity) │ │ │
│ │ │ - name │ │ - sent (timestamp) │ │ │
│ │ │ - online │ │ - text │ │ │
│ │ └────────────────┘ └─────────────────────────────────────┘ │ │
│ │ │ │
│ │ Reducers (server functions) │ │
│ │ ┌────────────────┐ ┌─────────────────────────────────────┐ │ │
│ │ │ send_message │ │ set_name │ │ │
│ │ │ (insert msg) │ │ (update user name) │ │ │
│ │ └────────────────┘ └─────────────────────────────────────┘ │ │
│ │ │ │
│ │ Lifecycle Hooks │ │
│ │ ┌────────────────┐ ┌─────────────────────────────────────┐ │ │
│ │ │ onConnect │ │ onDisconnect │ │ │
│ │ │ (create user) │ │ (mark offline) │ │ │
│ │ └────────────────┘ └─────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Understanding the Core Concepts
Before diving into the code, let’s unpack what makes SpacetimeDB tick. According to the official documentation, SpacetimeDB is a “real-time backend framework and database” that combines three things traditionally handled separately:
- Data storage (tables)
- Server logic (reducers)
- Real-time sync (subscriptions via WebSocket)
This is different from traditional architectures where you’d have a database (PostgreSQL), a backend API (Express, FastAPI), and then a separate mechanism for pushing updates to clients (WebSockets, polling, or something like Pusher). SpacetimeDB collapses all of this into one system.
Tables: Where Your Data Lives
Tables in SpacetimeDB work similarly to SQL tables — they’re structured collections of rows with defined columns and types. You define tables in your module (the server-side code), and they become part of your database schema.
The key distinction in SpacetimeDB is public vs. private tables:
- Public tables (
public: true): Anyone connected to the database can read these. This is what clients subscribe to for real-time updates. Think of these as the “read model” exposed to clients. - Private tables: Only reducers can access these. They’re for internal state that clients shouldn’t directly see.
In our chat app, both user and message are public because clients need to read both to display the chat UI.
Each row gets a special identity column type — this is a unique identifier for a user, similar to a wallet address. It’s generated automatically when a user connects and persists across sessions (backed by a token stored in localStorage).
Reducers: The Only Way to Write Data
This is the most important concept in SpacetimeDB: clients can never write directly to tables. Instead, they call reducers — server-side functions that run inside database transactions. Per the official docs:
“Reducers are functions that modify database state in response to client requests or system events. They are the only way to mutate tables in SpacetimeDB — all database changes must go through reducers.”
Reducers provide ACID transaction guarantees:
- Atomicity: Either all changes succeed, or none do. If your reducer throws an error, the entire operation rolls back.
- Isolation: Reducers don’t see changes from other concurrent reducers mid-execution.
- Consistency: The database always moves from one valid state to another.
- Durability: Once a reducer commits, data is persisted (SpacetimeDB uses an append-only Write-Ahead Log).
Reducers are also deterministic — they can’t make external HTTP requests, access the filesystem, or do anything with side effects. This ensures that the same input always produces the same result, which is critical for distributed systems. If you need to interact with external services, you’d use procedures instead (which are currently in beta and require manual transaction management).
In our chat app:
send_messageis a reducer that inserts a new row into the message tableset_nameis a reducer that updates a user’s display name
Lifecycle Hooks: Automatic Events
SpacetimeDB provides special reducers that run automatically on connection events (lifecycle reducers):
onConnect: Fires when a new client connects. Great for creating user records, setting up initial state, or broadcasting “user joined” messages.onDisconnect: Fires when a client disconnects. Useful for cleaning up temporary data or marking users as offline.init: Runs once when the module is first published or the database is cleared. Good for seeding initial data.
These are defined just like regular reducers but with special decorators (spacetimedb.clientConnected, etc.).
The Client-Side: WebSockets + Subscriptions
Clients connect to SpacetimeDB via WebSocket — not HTTP REST endpoints. This is what enables real-time updates. When you call useTable(tables.message), you’re setting up a subscription that:
- Sends an initial snapshot of all matching rows
- Keeps the WebSocket open
- Receives incremental updates whenever the table changes (inserts, updates, deletes)
- Automatically re-renders your React components
This is fundamentally different from polling — you get push updates with no extra requests. The subscription query can filter columns, so clients only receive data they need.
The client SDK handles all of this transparently. You just use hooks like useTable() and useReducer(), and the SDK manages the WebSocket connection, subscription updates, and reconnection logic.
File Breakdown
1. Server Module: spacetimedb/src/index.ts
This is the backend — the code that runs inside SpacetimeDB. According to the docs, modules are compiled to WebAssembly (or JavaScript bundles in v2.0) and executed by the SpacetimeDB runtime. This module defines your entire backend: tables, reducers, and lifecycle hooks.
// Tables define what data we store
const user = table(
{ name: "user", public: true },
{
identity: t.identity().primaryKey(),
name: t.string().optional(),
online: t.bool(),
}
);
const message = table(
{ name: "message", public: true },
{ sender: t.identity(), sent: t.timestamp(), text: t.string() }
);
Breaking down the table definitions:
-
table({ name: "user", public: true }, { ... }): The first argument is the table configuration —nameis how you’ll reference it in queries, andpublic: truemeans any connected client can subscribe to this table (read-only for clients, but more on that below). -
identity: t.identity().primaryKey(): Theidentitytype is a special SpacetimeDB construct — a globally unique, cryptographically-secure identifier for each connected user. It’s similar to a wallet address. Marking it asprimaryKey()means every user has exactly one row in this table (you can’t have duplicate identities). -
name: t.string().optional(): A nullable string column. The.optional()means it can benull(users don’t have to set a name initially). -
online: t.bool(): A boolean to track whether the user is currently connected.
For the message table:
sender: t.identity(): References theidentityof who sent the message (not marked as primary key because a user can send many messages).sent: t.timestamp(): Server-assigned timestamp when the message was sent. Usingctx.timestamp(server time) instead of client time ensures deterministic ordering.text: t.string(): The actual message content.
Key concepts:
public: true= anyone can READ this table via subscriptions- Only REDUCERS can WRITE to tables (not clients directly) — this is a core security model
t.identity()= unique user ID (like a wallet address, generated on first connection)
// Reducers = functions that modify data (the ONLY way to write to DB)
// https://spacetimedb.com/docs/functions/reducers/
export const send_message = spacetimedb.reducer(
{ text: t.string() }, // input parameters - defines what clients can pass
(ctx, { text }) => {
// function body - runs inside a database transaction
ctx.db.message.insert({ sender: ctx.sender, text, sent: ctx.timestamp });
}
);
What’s happening here:
-
spacetimedb.reducer(): Declares a reducer. The first argument defines the input schema (what parameters clients must provide). In this case, clients must pass atextstring. -
(ctx, { text }) => {...}: The reducer function. The first parameterctxis the ReducerContext — your gateway to the database and metadata. The second parametertextis the destructured input from the first argument. -
ctx.sender: The identity of whoever called this reducer. This is automatically populated by SpacetimeDB — clients can’t fake it. -
ctx.timestamp: The server’s current timestamp. Using server time (not client time) ensures all messages are ordered consistently across all clients. -
ctx.db.message.insert({...}): Inserts a row into the message table. This is the only way to write data — clients never directly insert rows.
Transaction guarantees: If this reducer throws an error (or if any part fails), the entire operation rolls back. No partial messages get saved. This is automatic — you don’t need to write rollback code.
// Lifecycle hooks - called automatically when clients connect/disconnect
// https://spacetimedb.com/docs/functions/reducers/lifecycle
export const onConnect = spacetimedb.clientConnected(ctx => {
ctx.db.user.insert({ identity: ctx.sender, name: undefined, online: true });
});
This runs automatically when a new client establishes a WebSocket connection. It creates a user row with:
- Their identity (from
ctx.sender) - No name yet (undefined)
- Marked as online
You could also emit a system message here (like “User joined the chat”) by inserting into the message table.
2. Generated Bindings: src/module_bindings/
Run spacetime generate to auto-create these files from your server code. This is one of SpacetimeDB’s killer features — you write your schema in TypeScript on the server, and the CLI auto-generates strongly-typed client-side code. No more manually keeping client types in sync with your database schema!
module_bindings/
├── index.ts # Main exports: tables, reducers, DbConnection
├── message_table.ts # TypeScript type for message rows
├── user_table.ts # TypeScript type for user rows
├── send_message_reducer.ts # Type for reducer arguments
├── set_name_reducer.ts # Type for reducer arguments
└── types/
└── reducers.ts # Combined reducer types
What they provide:
- Type safety for tables and reducers: The generated types match exactly what you defined in your module. If you change a column type on the server, TypeScript will flag client code that breaks.
tables.message: A subscription builder — pass this touseTable()to subscribe to message updatesreducers.sendMessage: A callable function — pass input args and it sends the reducer call over WebSocket
This is similar to how GraphQL Code Generator works, but it’s built into SpacetimeDB’s workflow. The generation happens every time you publish (spacetime publish), keeping client and server in sync.
3. Connection Provider: src/app/providers.tsx
This sets up the WebSocket connectionDB. The React to Spacetime SDK uses a provider pattern — wrap your app in SpacetimeDBProvider and all child components can access the connection via hooks.
const builder = DbConnection.builder()
.withUri('ws://localhost:3000') // SpacetimeDB server address
.withDatabaseName('chat-app') // database name
.withToken(localStorage.getItem(tokenKey)) // stored auth token
.onConnect(onConnect) // callback when connected
.onConnectError(onConnectError); // callback on error
return <SpacetimeDBProvider connectionBuilder={builder}>
Understanding the connection flow:
-
DbConnection.builder(): Creates a builder pattern for configuring the connection. You specify where to connect and what to do on events. -
.withUri('ws://localhost:3000'): The WebSocket endpoint of your SpacetimeDB server. In development, this is typically localhost:3000 (wherespacetime startruns). In production, this would be your hosted SpacetimeDB instance. -
.withDatabaseName('chat-app'): The name of the database you published. When you runspacetime publish chat-app, it creates a database named “chat-app”. -
.withToken(localStorage.getItem(tokenKey)): This is key to identity persistence. When a user first connects, SpacetimeDB generates a new identity and returns a token. Store this token in localStorage (or a cookie), and on subsequent visits, pass it back. The user keeps the same identity across sessions — their messages and data persist. -
.onConnect()and.onConnectError(): Callbacks for connection events. The onConnect callback receives the connection and identity, which is where you’d typically store the token for the first time.
What SpacetimeDBProvider does:
- Manages the WebSocket connection lifecycle (connect, reconnect on disconnect, heartbeat)
- Provides context values that hooks can consume
- Handles subscription management under the hood
In Next.js/App Router, you’d typically wrap your root layout or a dedicated provider component with this.
4. Chat UI: src/app/page.tsx
This is the client — runs in the browser. The React SDK gives you hooks that abstract away the WebSocket and subscription machinery. You just use them like local state, but they’re actually backed by real-time data from the server.
// Hooks to interact with SpacetimeDB
const { identity, isActive: connected } = useSpacetimeDB(); // connection status
const [messages] = useTable(tables.message); // subscribe to messages
const [users] = useTable(tables.user); // subscribe to users
const sendMessageReducer = useReducer(reducers.sendMessage); // call reducer
// To send a message:
sendMessageReducer({ text: newMessage });
Breaking down each hook:
-
useSpacetimeDB(): Returns the connection state.identityis the current user’s identity (their unique ID in the system), andisActivetells you if the WebSocket is connected. -
useTable(tables.message): This is the magic. You’re subscribing to themessagetable. The returned[messages]is an array that:- Starts with the current state of all messages
- Automatically updates when anyone inserts/updates/deletes rows
- Triggers React re-renders on changes
Under the hood, the SDK opens a subscription via WebSocket. When the server detects a table change, it pushes an update message. The SDK applies it to local state and your component re-renders with new data. No polling, no manual refresh.
-
useReducer(reducers.sendMessage): Returns a function you call to invoke the server-side reducer. When you callsendMessageReducer({ text: "Hello" }):- The SDK serializes the arguments
- Sends them over WebSocket to the server
- SpacetimeDB runs the
send_messagereducer in a transaction - If it succeeds, the message table updates, triggering subscriptions
- All connected clients (including the sender) receive the update
How data flows:
useTable()subscribes to table changes via WebSocket- When ANY user sends a message, ALL connected clients get updated automatically
- No manual refresh needed - real-time by default
This is fundamentally different from REST APIs where you’d:
- POST /messages to create
- Poll GET /messages to refresh
- Manually merge new messages into state
With SpacetimeDB, the data flow is inverted — the server pushes changes, and your UI reacts.
How a Message is Sent
Let’s trace through exactly what happens when a user sends a message:
User types message → clicks Send
│
▼
page.tsx: sendMessageReducer({ text: "Hello" })
│
▼
useReducer hook sends via WebSocket to SpacetimeDB
│
▼
SpacetimeDB server validates & runs send_message reducer
│
▼
Reducer inserts into message table
│
▼
SpacetimeDB notifies ALL connected clients of new message
│
▼
All clients' useTable(tables.message) updates
│
▼
React re-renders with new messages
Step-by-step breakdown:
-
User clicks Send: The React component calls
sendMessageReducer({ text: "Hello" }). This is just a local function call — no network request yet. -
SDK serializes and sends: The
@spacetimedb/reactSDK takes the arguments, serializes them to JSON, and sends a WebSocket message to the SpacetimeDB server atws://localhost:3000. -
Server receives and validates: SpacetimeDB looks up the reducer by name, validates the input against the schema (
text: t.string()), and executes the reducer function in a transaction. -
Reducer executes: The
send_messagereducer runs withctx.senderset to the caller’s identity andctx.timestampset to server time. It inserts a new row into themessagetable. -
Transaction commits: If no errors, the insert is committed. The message is now persisted and visible to all subscribers.
-
Subscription engine fires: SpacetimeDB’s subscription engine detects the table change. It looks at all connected clients who have subscribed to the
messagetable and prepares update messages. -
WebSocket push: The server pushes update messages to all connected clients via WebSocket. This includes the sender (so they see their own message) and all other connected clients.
-
Client SDK updates local state: Each client’s SDK receives the update, applies it to its local cache, and triggers a React state update.
-
React re-renders: The component using
useTable(tables.message)re-renders with the new message array.
The entire round trip — from click to render — typically takes milliseconds. And here’s the key: every client sees the same data. There’s no race condition where Client A sees the message but Client B doesn’t. The transaction provides a consistent ordering, and the subscription system ensures all clients converge to the same state.
Key SpacetimeDB Concepts
| Concept | Purpose | Learn More |
|---|---|---|
| Table | Structured data storage with columns and types | Tables docs |
| Reducer | Server-side function that runs in a transaction — the ONLY way to write data | Reducers docs |
| Public table | Readable by clients via subscriptions; only reducers can write | Table config public: true |
| Identity | Globally unique user identifier, like a wallet address | Identity docs |
| ctx.sender | The identity of whoever invoked the current reducer | Automatically populated |
| ctx.timestamp | Server timestamp at reducer invocation time | Ensures deterministic ordering |
| useTable() | Subscribe to table changes; returns reactive array that updates in real-time | Client SDK |
| useReducer() | Call a server-side reducer function from the client | Client SDK |
| Lifecycle hooks | onConnect, onDisconnect, init — auto-run on events | Lifecycle docs |
| View | Read-only server function that computes derived data (like aggregations) | Views docs |
How to Extend
Add a new reducer:
- Add to
spacetimedb/src/index.ts:
export const delete_message = spacetimedb.reducer(
{ id: t.u64() },
(ctx, { id }) => {
ctx.db.message.id.delete(id);
}
);
- Run
spacetime generateto update bindings - Use in UI:
const deleteMessage = useReducer(reducers.deleteMessage);
deleteMessage({ id: messageId });
Add a new table:
- Add to server module
- Run
spacetime publishto update database - Run
spacetime generatefor bindings - Use
useTable(tables.newTable)in client