the Room Protocol · architecture
A stateless MCP server on a single Redis
roomd, the reference server for the Room Protocol, remembers nothing between requests, backed by a single Redis instance and nothing else. That sounds austere. It's the most load-bearing decision in the whole system.
When you sit down to build a service that many people will depend on, the reflex stack shows up almost on its own: an app server for logic, Postgres for durable data, Redis as a cache in front of it, maybe a session store, maybe a separate auth database. It's the default because it's safe and everyone knows it. It's also five stateful things to provision, secure, back up, migrate, and pay for.
roomd runs on two boxes: a stateless server and one Redis. There is no Postgres, no cache tier, no session store, no separate auth DB. This post is the argument for that, made honestly, including where it bites.
Stateless, in the strong sense
The server holds no state between requests. Not “mostly” — at all. Each hit on the /mcp endpoint spins up a fresh MCP server and transport, handles that one request, and throws both away. Every durable effect lands in Redis:
app.all("/mcp", requireAuth, async (c) => {
const keyCtx = c.get("keyCtx");
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode — no session to pin
});
const server = createMcpServer(keyCtx); // fresh per request
await server.connect(transport);
return transport.handleRequest(c.req.raw);
});The alternative most MCP servers reach for is a long-lived connection: a session established over SSE, kept alive, with per-connection state living in the server's memory. That works, but it drags a tail of problems behind it. Sessions have to be pinned to a specific instance, so you need sticky routing. You can't restart or deploy without dropping live connections. Scaling out means sharing or migrating session state. Every one of those is a distributed-systems headache you signed up for the moment the server started remembering things.
Statelessness deletes all of it. Any instance can serve any request, because no instance knows anything the others don't. You can kill a server mid-flight and the next request lands somewhere else and just works. Horizontal scaling is “run more copies.” A deploy is “replace the copies.” The correctness argument is trivial precisely because there's no shared in-memory state to reason about.
Statelessness only works if something else holds the state reliably and cheaply. That job goes entirely to one Redis — which is a stranger choice than it looks.
Redis as the only database, not the cache
Redis usually plays the supporting role: a fast cache in front of the “real” database. Here it is the real database. Rooms, plans, context, events, presence, locks, API keys — all of it lives in one Redis, and there is no Postgres behind it.
The instinct that this is reckless comes from assuming you need what a relational database gives you: schemas, joins, transactions across tables, ad-hoc queries. Look at what a coordination room actually does and most of that need evaporates. The access patterns are simple and known ahead of time: fetch this room's plan, list this room's context, read the last N events, check if this agent is alive. Every one is a direct key lookup or a small list read. There are no reports to run, no analytics joins, no cross-room queries. When your access patterns are all “get the thing at this key,” a key-value store isn't a compromise — it's the correct shape.
The one thing Redis can't do — “list all keys matching a pattern” efficiently — is handled by keeping companion index sets by hand. A room's context ids live in a set so listing them is one read, not a scan:
// write: store the entry AND register its id in the room's index set
await redis.set(`${roomId}:context:${id}`, JSON.stringify(entry));
await redis.sadd(`${roomId}:context:index`, id);
// list: read the index, then fetch each — no keyspace scan
const ids = await redis.smembers(`${roomId}:context:index`);That's the whole trade: you maintain a couple of index sets manually in exchange for never running a second database. For a handful of collections with known shapes, that's a bargain.
TTL does the work a cron job would
The most underrated part of leaning on Redis is that expiry is a first-class feature, and it quietly replaces a whole category of cleanup code. Three different subsystems get their correctness from a TTL rather than from a background job:
In a Postgres world every one of these would be a scheduled job scanning for stale rows — more code, more moving parts, more things to monitor. In Redis it's an argument on a SET. The database garbage-collects your correctness for you.
Upstash over HTTP: the piece that makes it click
One more choice makes statelessness and single-store fit together cleanly: the Redis is Upstash, accessed over HTTP rather than a raw TCP connection. A normal Redis client opens and pools long-lived TCP connections, which is another bit of per-instance state and a poor fit for serverless or edge runtimes that spin up and tear down constantly. Upstash speaks HTTP, so each operation is a stateless request. No pool to manage, no connection lifecycle, no warm-up. A stateless server talking to a store over stateless requests is a coherent whole, top to bottom.
There is a cost story underneath this, and it is a good one. The whole stack runs for a rounding error a month and one person can operate it: no database to tune, no failover to rehearse, no migration to fear on deploy. For anything bootstrapped, the number of stateful systems you run is a direct tax on your time and your runway, and two is a very different life than five.
What you give up
I'd be selling you something if I pretended this were free. The honest costs:
None of these are hit at the current scale, and each has a clear escape hatch when it is — read replicas, a purpose-built search index, TTLs on event logs. The point of the bet isn't that one Redis scales forever. It's that starting with the smallest stack that's actually correct buys you speed and cheapness now, and the migrations you might someday need are ones you'll happily do because you have users.
The reflex stack is what you build when you're guessing at requirements and want to be ready for anything. A stateless server on one Redis is what you build when you've looked hard at what the thing actually does and refused to carry a single component it can't justify. Nobody ever got paged at 3am about the database they didn't run.
— shreyas
more in this series
01Coordinating agents through shared state · the idea
02Concurrency control for a shared plan · locks and cursors
03A stateless server on a single Redis · you are here
04Typed context over prose and vector search · context model
05MCP as transport · protocol layer
06Multi-tenancy on one Redis · isolation and access