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.js handler 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 you ctx.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 future onAlarm tick; ctx.save() — persist ctx.state now.

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.