Realtime
env.ROOM gives your app live, multiplayer features over WebSockets — a named channel any number of clients connect to. Use it for chat, presence (who's here), live cursors, or a full game with server-authoritative state. The room state is managed for you; you never run a socket server or manage connections.
env.ROOM is a paid-plan feature. It wires up on deploy when your code references env.ROOM (or you ship an api/room.js handler). Idle rooms cost nothing until a message flows.Two ways to use it
- Fan-out (default) — a channel that relays messages between clients, with presence and server-side broadcast built in. Perfect for chat, cursors, “watch together”.
- Authoritative (opt-in) — ship an
api/room.jshandler whose code runs on the server, inside the room to own game state, validate moves (anti-cheat), send hidden per-client information, and drive server ticks. This is what real competitive or hidden-information multiplayer needs.
Fan-out: connect clients
Upgrade an incoming WebSocket into a room from any api/*.js route with env.ROOM.get(name).join(request, metadata?). Return what it gives you — that's the connection response.
// api/live.js — the WebSocket endpoint clients connect to
export default {
async fetch(request, env) {
const room = new URL(request.url).searchParams.get("room") || "lobby";
// metadata (optional) is attached to the connection and shown in presence()
return env.ROOM.get(room).join(request, { user: "alice" });
},
};On the client, open a WebSocket to that route:
const ws = new WebSocket(`wss://${location.host}/api/live?room=lobby`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
// { type: "welcome", id, clients } → sent to you when you join
// { type: "presence", clients } → when anyone joins or leaves
// { type: "message", from, data } → when another client sends
};
// Anything you send is relayed to everyone else in the room
ws.send(JSON.stringify({ text: "hello everyone" }));Broadcast & presence from server code
You don't need a connected socket to push into a room — do it from a route or a scheduled job.
// Push a message to everyone in the room
await env.ROOM.get("lobby").broadcast({ type: "announcement", text: "Starting soon" });
// Who's connected right now
const clients = await env.ROOM.get("lobby").presence();
// [{ id, info, joinedAt }, ...] (info = the metadata passed to join())Authoritative rooms (multiplayer, anti-cheat)
Ship an api/room.js whose exported handlers run server-side inside the room. Now there is no automatic fan-out — your code decides what each client sees, so a player can never send a move the server didn't validate, and hidden information (another player's hand) never leaves the server.
// api/room.js — every export is optional
export default {
initialState() {
return { turn: 0, board: emptyBoard() }; // first state for a new room
},
async onJoin(ctx, client) {
ctx.send(client.id, { type: "state", state: ctx.state }); // just to them
},
async onMessage(ctx, client, move) {
if (!isLegalMove(ctx.state, client.id, move)) return; // validate first
ctx.state = applyMove(ctx.state, move);
ctx.broadcast({ type: "state", state: ctx.state }); // to everyone
await ctx.save(); // persist
},
async onLeave(ctx, client) {
ctx.broadcast({ type: "left", id: client.id });
},
async onAlarm(ctx) {
// a scheduled server tick — countdowns, physics, AFK sweeps
},
};Each handler receives a room context:
ctx.state— read/write the room's state (persisted across reconnects and redeploys when youctx.save()).ctx.broadcast(msg)— send to every client;ctx.send(clientId, msg)— send to one (hidden info).ctx.clients()— connected clients;ctx.roomId— the room's id.ctx.setAlarm(unixMs)— schedule a futureonAlarmtick;ctx.save()— persistctx.statenow.
client is { id, info, joinedAt } — info is the metadata passed to join().
API
env.ROOM.get(name)— a handle to the named room (created on first use)..join(request, metadata?)— upgrade an inbound WebSocket into the room; return the result from your route..broadcast(message)— send a message to every client →{ ok, delivered }..presence()— list connected clients →[{ id, info, joinedAt }].
Notes
- Rooms are addressed by name — everyone who calls
get("lobby")shares one room. Use per-game or per-document names (e.g.game:${id}) to isolate them. - Fan-out relays JSON automatically; the moment you ship
api/room.js, relaying stops and your handler is in full control. - Deploying to a free plan fails with a clear message — realtime is a paid feature.