Distributed systems
Part 4 of 6 · Durable ObjectsDurable Objects Real-Time - WebSocket Hibernation, Alarms, Chat, Presence & Collaborative Editing
Real-time: WebSocket Hibernation API (acceptWebSocket, tags, attachments, auto-response), real chat room with presence and history, alarms, collaborative editing via CRDT or server ordering, fan-out batching and backpressure, hibernation cost math ($20.65 vs $420.65 docs example), transport decision chart.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
How do all members of a room reach the same server?
Answer
The Worker derives the room's ID with getByName, so every connection lands on that room's object.
L2
What does the Hibernation API keep and what does it drop?
Answer
It keeps sockets open and drops the object's memory between messages.
L3
Where does per-connection data live?
Answer
In serializeAttachment, which survives hibernation.
L4
How is presence rebuilt after hibernation?
Answer
From getWebSockets() and their attachments.
L5
How do you schedule many reminders with one alarm?
Answer
Store them in a table by due time and let alarm() set the next due time.
L6
Why batch fan-out?
Answer
Batching cut frames from 400,000 to 40,000 in the simulation at a small latency cost.
L7
What do you do with a slow client?
Answer
Bound its queue and disconnect it instead of buffering without limit.
Failure modes
In-memory room state lost after hibernation
Fields set in memory vanish when the object wakes, so presence or history kept only in memory disappears.
Unbounded buffers for slow clients
A slow reader makes the object queue frames forever and exhausts memory.
Alarms that are not idempotent
Alarms are at least once, so cleanup or delivery can run twice.
Misconceptions
Hibernation closes the WebSockets.
The runtime keeps sockets open while the object is evicted from memory.
Each object can have many alarms.
There is one alarm per object. Keep a table of due times and reschedule.
One room object scales to any audience.
Very large rooms need batching, relay objects, or a read-only stream.
Interviewer traps
Using the standard WebSocket API for idle-heavy rooms.
Use acceptWebSocket so idle time does not bill duration.
Persisting typing indicators and cursors to storage.
Keep ephemeral signals off storage and send them directly.
Design scenario
Same prompt for every reader.
Requirements
Ordered messages per room, presence that survives restarts, history on join, and predictable cost for idle rooms.
Failure assumptions
- Deploys drop every socket.
- Some clients are on slow mobile links.
- Alarms can fire twice.
Constraints
- No sticky load balancer or Redis pub/sub.
- Idle rooms must not bill duration.
Prompt
Design live chat with presence and history for 50,000 rooms, where a few rooms reach 20,000 viewers.
API
Which messages does the client send and receive on join, post and leave?
Data
What goes in attachments, what goes in SQLite, and what stays ephemeral?
Architecture
When does a room switch to relay objects or a read-only stream?
Overview
Real-time apps need one place where every participant's messages meet in a single order, and they need to hold thousands of mostly idle connections cheaply. A Durable Object per room does both. Clients open WebSockets that the front Worker forwards to the room's object. The object accepts them with the Hibernation API, so the runtime keeps the sockets open while the object itself can be evicted from memory and stop accruing duration charges between messages. Each incoming frame wakes the object, runs webSocketMessage(), persists to SQLite, and fans the message out. Alarms give each room its own timer for retention, idle cleanup or batching. Chat, presence, cursors, game lobbies and collaborative editing are all variations on this pattern.
How the code was checked: The real Durable Object TypeScript on this page type-checks with
tsc --strictagainst@cloudflare/workers-typesand was run locally underwrangler dev4.148.0 (workerd 2026-10-06) and the@cloudflare/vitest-plugintest pool. Blocks labeled simulation are sandbox models of the semantics, not Cloudflare code. Limits and prices are quoted from Cloudflare's docs as of 2026-10-07.
Why this matters
- Interviews: "Design Slack / Google Docs / a multiplayer lobby" needs answers for connection routing, ordering, presence, history and fan-out. With DOs the answer to "which server holds this room's sockets?" is simply "the room's object".
- Production: the cost difference is large. On the docs' own pricing example, 100 rooms of 100 sockets cost about $20.65 per month with hibernation and about $420 per month if the same objects stay pinned in memory using the standard WebSocket API (we reproduce the math below).
Hibernation API vs standard WebSocket API
| Hibernation API (recommended) | Standard API (ws.accept() plus addEventListener) | |
|---|---|---|
| Accept | ctx.acceptWebSocket(ws, tags); do not call ws.accept() | ws.accept() |
| Events | Class methods webSocketMessage, webSocketClose, webSocketError | Event listeners on the socket |
| Idle object | Can hibernate; sockets stay connected | Pinned in memory; billed for duration the whole time |
| Per-connection state | serializeAttachment() (up to 16,384 bytes), survives hibernation | Plain variables |
| Ping and pong | Protocol pings answered automatically; setWebSocketAutoResponse("ping","pong") answers app-level pings without waking the object | You handle them |
| Max sockets per object | 32,768 per the State API docs (CPU and memory usually cap you first) | Bounded by memory |
| Incoming message size | 32 MiB | 32 MiB |
The lifecycle docs say an object becomes eligible to hibernate after about 10 seconds with no events, provided there are no pending setTimeout or setInterval callbacks, no unfinished I/O or waitUntil promises, no standard-API WebSockets, and no request in progress. When it wakes, the constructor runs again, so keep the constructor cheap and rebuild per-socket state from attachments.
How does a room hold thousands of idle sockets?
Prefer
Hibernating WebSockets in a room object
The runtime keeps sockets open while the object leaves memory between messages.
- Duration billing stops while idle.
- Attachments and SQLite survive hibernation.
- Presence is rebuilt from live sockets.
Alternative
Standard WebSockets held in memory
The object stays awake as long as any socket is open.
- Duration cost about 20x higher in the docs example.
- Memory-only state looks fine until the first eviction.
- Big rooms still need batching and backpressure.
One chat message through a room object
Diagram 1 condensed. The real chat room shows history, presence and broadcast.
- 1
Route by room name
The Worker forwards the upgrade to the room's object. - 2
Accept with hibernation
acceptWebSocket lets the object leave memory between frames. - 3
Persist, then broadcast
webSocketMessage writes to SQLite and fans out to every socket. - 4
Protect against slow clients
Batch frames and disconnect clients whose queue overflows.
How a chat message flows (step-labeled)
Flow
- 1
1. Client opens wss://app/chat/general
- next2. Worker checks Upgrade header and auth
- 2
2. Worker checks Upgrade header and auth
- next3. getByName room:general, forward request to the object
- F2. not a WebSocket426 from the Worker, the object is never billed
- 3
3. getByName room:general, forward request to the object
- next4. Object: acceptWebSocket with tag, serializeAttachment user
- 4
4. Object: acceptWebSocket with tag, serializeAttachment user
- next5. Send history from SQLite, broadcast presence
- 5
5. Send history from SQLite, broadcast presence
- next6. No events for about 10 s: object hibernates, sockets stay open
- 6
6. No events for about 10 s: object hibernates, sockets stay open
- next7. Frame arrives: constructor runs, then webSocketMessage
- 7
7. Frame arrives: constructor runs, then webSocketMessage
- next8. INSERT into SQLite (output gate holds sends until durable)
- F3. deploy restarts the objectAll sockets drop: clients reconnect and resync from history
- 8
8. INSERT into SQLite (output gate holds sends until durable)
- next9. Broadcast one serialized frame to getWebSockets()
- 9
9. Broadcast one serialized frame to getWebSockets()
- next10. Hourly alarm prunes old messages
- F1. send to a dead socket throwsCatch per client, presence fixed in webSocketClose
- 10
10. Hourly alarm prunes old messages
- 11
Catch per client, presence fixed in webSocketClose
- 12
426 from the Worker, the object is never billed
- 13
All sockets drop: clients reconnect and resync from history
Lesson map
Durable Objects Real-Time - WebSocket Hibernation, Alarms, Chat, Presence & Collaborative Editing
Real-time: WebSocket Hibernation API (acceptWebSocket, tags, attachments, auto-response), real chat room with presence and history, alarms, collaborative editing via CRDT or server ordering, fan-out batching and backpressure, hibernation cost math ($20.65 vs $420.65 docs example), transport decision chart.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB u["1. Client opens wss://app/chat/general"] w["2. Worker checks Upgrade header and auth"] s["3. getByName room:general, forward request to the object"] a["4. Object: acceptWebSocket with tag, serializeAttachment user"] h["5. Send history from SQLite, broadcast presence"] z["6. No events for about 10 s: object hibernates, sockets stay open"] m["7. Frame arrives: constructor runs, then webSocketMessage"] p["8. INSERT into SQLite (output gate holds sends until durable)"] b["9. Broadcast one serialized frame to getWebSockets()"] al["10. Hourly alarm prunes old messages"] e["426 from the Worker, the object is never billed"] c["Catch per client, presence fixed in webSocketClose"] r["All sockets drop: clients reconnect and resync from history"] u -->|continues| w w -->|continues| s s -->|continues| a a -->|continues| h h -->|continues| z z -->|continues| m m -->|continues| p p -->|continues| b b -->|continues| al b -->|F1. send to a dead socket throws| c w -->|F2. not a WebSocket| e m -->|F3. deploy restarts the object| r
Decision chart: which real-time transport?
Decisions
- 1
1. Do clients send as well as receive?
- no, read-only feedSSE or polling via Worker, cache at the edge
- yes2. Do messages need one shared order per room or doc?
- 2
SSE or polling via Worker, cache at the edge
- ?
2. Do messages need one shared order per room or doc?
- noPoint-to-point: a per-user object or a queue
- yes3. Long idle periods between messages?
- 4
Point-to-point: a per-user object or a queue
- ?
3. Long idle periods between messages?
- yes (chat, presence, docs)Durable Object per room with the Hibernation API
- no, constant traffic (games)Durable Object per match; hibernation saves less, batch frames
- 6
Durable Object per room with the Hibernation API
- next4. More than a few thousand sockets in one room?
- 7
Durable Object per match; hibernation saves less, batch frames
- next4. More than a few thousand sockets in one room?
- ?
4. More than a few thousand sockets in one room?
- yesRelay tree: broadcast object fans out to relay objects
- noOne object per room is enough
- 9
Relay tree: broadcast object fans out to relay objects
- 10
One object per room is enough
Real code: a hibernating chat room with presence
// ChatRoom: one Durable Object per room. Hibernatable WebSockets + SQLite history + an alarm for retention.
import { DurableObject } from "cloudflare:workers";
import type { Env } from "./index";
type Attachment = { user: string; joinedAt: number }; // survives hibernation (max 16,384 bytes serialized)
const HISTORY_LIMIT = 50; // messages replayed to a new joiner
const RETENTION_MS = 7 * 24 * 3600e3; // prune messages older than 7 days
export class ChatRoom extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Runs on every cold start AND every wake from hibernation, so keep it cheap.
ctx.blockConcurrencyWhile(async () => {
// SQL calls are synchronous; blockConcurrencyWhile guarantees no event runs before the schema exists.
ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT NOT NULL, body TEXT NOT NULL, ts INTEGER NOT NULL)`);
});
// Answered by the runtime without waking the object (no duration billed).
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
}
async fetch(request: Request): Promise<Response> {
const user = new URL(request.url).searchParams.get("user") ?? "anon";
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Hibernation API: the runtime owns the socket. Do NOT call server.accept().
this.ctx.acceptWebSocket(server, [user]); // tag = user, so getWebSockets(user) finds all their tabs
server.serializeAttachment({ user, joinedAt: Date.now() } satisfies Attachment);
// Replay recent history to the joiner only. toArray() consumes the cursor before any await.
const history = this.ctx.storage.sql
.exec("SELECT user, body, ts FROM messages ORDER BY id DESC LIMIT ?", HISTORY_LIMIT)
.toArray().reverse();
server.send(JSON.stringify({ type: "history", messages: history }));
this.broadcast({ type: "presence", online: this.online() });
// Make sure a retention alarm exists (only one alarm per object; check before setting).
if ((await this.ctx.storage.getAlarm()) === null) {
await this.ctx.storage.setAlarm(Date.now() + 3600e3);
}
return new Response(null, { status: 101, webSocket: client });
}
// Called once per incoming frame; the object may have been hibernated a moment ago.
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
const { user } = ws.deserializeAttachment() as Attachment; // in-memory fields are gone after hibernation
const body = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
if (body.length > 4000) { ws.send(JSON.stringify({ type: "error", reason: "too large" })); return; }
const ts = Date.now();
// Not awaited: the output gate holds the broadcast below until this write is durable.
this.ctx.storage.sql.exec("INSERT INTO messages(user, body, ts) VALUES (?, ?, ?)", user, body, ts);
this.broadcast({ type: "message", user, body, ts });
}
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
// With compatibility_date >= 2026-04-07 the runtime completes the close handshake for us.
this.broadcast({ type: "presence", online: this.online(ws) });
}
async webSocketError(ws: WebSocket): Promise<void> {
this.broadcast({ type: "presence", online: this.online(ws) });
}
// Alarm handlers can run more than once: deleting by age is naturally idempotent.
async alarm(): Promise<void> {
this.ctx.storage.sql.exec("DELETE FROM messages WHERE ts < ?", Date.now() - RETENTION_MS);
if (this.ctx.getWebSockets().length > 0) await this.ctx.storage.setAlarm(Date.now() + 3600e3);
}
private online(exclude?: WebSocket): string[] {
const users = this.ctx.getWebSockets()
.filter((s) => s !== exclude)
.map((s) => (s.deserializeAttachment() as Attachment).user);
return [...new Set(users)].sort();
}
// Fan-out: one serialization, N sends. A send to a dead socket throws, so isolate failures per client.
private broadcast(msg: unknown): void {
const frame = JSON.stringify(msg);
for (const s of this.ctx.getWebSockets()) {
try { s.send(frame); } catch { /* socket already closing; webSocketClose will clean up presence */ }
}
}
}Real run (local wrangler dev): Alice joins and posts, Bob joins and gets the history, both chat, Alice sends an app-level ping, Bob leaves, then a plain GET hits the route.
Output (captured from the real run):
bob received:
{"type":"history","messages":[{"user":"alice","body":"hello before bob joined","ts":<t>}]}
{"type":"presence","online":["alice","bob"]}
{"type":"message","user":"bob","body":"hi alice","ts":<t>}
alice received:
{"type":"history","messages":[]}
{"type":"presence","online":["alice"]}
{"type":"message","user":"alice","body":"hello before bob joined","ts":<t>}
{"type":"presence","online":["alice","bob"]}
{"type":"message","user":"bob","body":"hi alice","ts":<t>}
pong
alice after bob left: {"type":"presence","online":["alice"]}
plain GET to /chat -> 426 {"error":"expected websocket"}Things to notice: Bob's first frame is the history replay that includes Alice's earlier message. Presence updates arrive as each person joins and leaves. The pong reply came from the runtime's auto-response, not from webSocketMessage. The non-WebSocket request was rejected by the Worker before it reached the object, so it is not billed against the object.
Alarms: one timer per object
ctx.storage.setAlarm(ms)schedulesalarm(). There is one alarm per object. Setting it again replaces the old time. To run many timers, keep a schedule table and havealarm()process what is due, then re-arm for the next one.- Delivery is at least once. A throw is retried with exponential backoff starting at 2 seconds, up to 6 retries. Make handlers idempotent.
- Alarms usually fire within a few milliseconds of the set time, but can be delayed up to a minute during failover.
- If you set an alarm in the constructor, check
getAlarm()first. A cold start runs the constructor before the pendingalarm(), and you could overwrite it. - An alarm wakes an object (and prevents hibernation while it runs). Do not wake every room every few seconds just to check something.
Presence, typing indicators and cursors
- Presence is derived from the open sockets (
getWebSockets()plus attachments), so it heals itself after a hibernation. Do not keep a separate in-memory set that can drift. - Ephemeral signals (typing, cursor positions) should not be written to SQLite. Batch them: collect for 50 to 100 ms and send one frame. The docs recommend batching 10 to 100 logical messages per frame because every frame costs a context switch.
- Multiple tabs per user: tag sockets with the user ID and use
getWebSockets(userId).
Collaborative editing
A document object is the single sequencer for its edits:
| Approach | How the object helps | Trade-off |
|---|---|---|
| Server-ordered operations (OT style) | Assigns every op a sequence number and rebroadcasts in order | Simple and authoritative; clients must transform pending local ops |
| CRDT (for example Yjs or Automerge updates) | Stores and relays updates, persists merged state or snapshots to SQLite | Works offline and peer to peer; larger metadata |
| Last writer wins per field | Trivial | Silently loses concurrent edits |
A practical design: the document object appends each update to SQLite, periodically compacts into a snapshot (in an alarm), and sends new joiners the snapshot plus the tail. Large or busy documents can be split into one object per section, with a parent object for the index.
Fan-out and backpressure
One room object does all the sending, so fan-out cost is clients times messages. Two levers matter: send fewer frames, and do not let one slow reader grow memory forever. Simulation (not Cloudflare code):
// SIMULATION (not Cloudflare code): fan-out cost in one room object, with and without batching,
// plus a bounded per-client queue so one slow reader cannot grow memory forever.
const clients = 200, msgsPerSec = 200, seconds = 10, windowMs = 50;
// Without batching: every incoming message becomes one frame per client.
const framesNaive = msgsPerSec * seconds * clients;
// With batching: messages inside a 50 ms window are packed into one frame per client.
const windows = (seconds * 1000) / windowMs;
const framesBatched = windows * clients;
console.log(`naive frames sent: ${framesNaive.toLocaleString()}`);
console.log(`batched frames sent: ${framesBatched.toLocaleString()} (${(framesNaive / framesBatched).toFixed(0)}x fewer, +<=${windowMs} ms latency)`);
// Slow consumer: drains 5 frames/s while we produce 20/s (one batch frame every 50 ms).
type Client = { queue: number; dropped: number; closed: boolean };
const fast: Client = { queue: 0, dropped: 0, closed: false };
const slow: Client = { queue: 0, dropped: 0, closed: false };
const CAP = 100; // per-client buffer cap, in frames
for (let t = 0; t < windows; t++) {
for (const [c, drainPerWindow] of [[fast, 1], [slow, 0.25]] as [Client, number][]) {
if (c.closed) continue;
c.queue = Math.max(0, c.queue + 1 - drainPerWindow);
if (c.queue > CAP) { c.dropped++; c.queue = CAP; }
if (c.dropped > 20) c.closed = true; // policy: close with 1013 and let the client reconnect + resync
}
}
console.log("fast client:", JSON.stringify(fast));
console.log("slow client:", JSON.stringify(slow), "<- disconnected instead of buffering without bound");Output (simulation):
naive frames sent: 400,000
batched frames sent: 40,000 (10x fewer, +<=50 ms latency)
fast client: {"queue":0,"dropped":0,"closed":false}
slow client: {"queue":100,"dropped":21,"closed":true} <- disconnected instead of buffering without boundExpectednaive frames sent: 400,000 batched frames sent: 40,000 (10x fewer, +<=50 ms latency) fast client: {"queue":0,"dropped":0,"closed":false} slow client: {"queue":100,"dropped":21,"closed":true} <- disconnected instead of buffering without bound
Press Run. Snippets must be self-contained — no network, files, or native modules.
Patterns that follow:
- Batch frames per time window (trades up to one window of latency for 10x fewer sends here).
- Bound what you keep per client. If a client falls behind, close it (code 1013, "try again later") and let it reconnect and resync from history instead of buffering forever.
- Fan out in a tree for huge audiences: a broadcast object pushes to N relay objects, each holding a slice of the sockets.
- Keep validation and auth in the Worker so junk never reaches the room.
What hibernation is worth (cost model)
This reproduces the docs' pricing Example 4 (100 objects times 100 hibernatable sockets, one message per socket per minute, 10 ms of work per message) and then prices the same traffic with the objects pinned in memory all month. Prices and rounding rules come from the pricing page. This is a cost model, not Cloudflare code.
# SIMULATION (cost model, not Cloudflare code). Prices and rules are copied from the Durable Objects
# pricing page (Workers Paid): $0.15 per million requests over 1M included, $12.50 per million GB-s over
# 400,000 GB-s, 128 MB billed per object, incoming WebSocket messages billed 20:1, billable usage rounded
# UP to the next whole million, plus the $5 minimum. Reproduces the docs' Example 4.
import math
def cost(objects, conns, msgs_per_conn_per_min, active_seconds_per_object, days=30):
connect_reqs = objects * conns
ws_msgs = conns * msgs_per_conn_per_min * objects * 60 * 24 * days
reqs = connect_reqs + ws_msgs / 20
req_cost = math.ceil(max(0, reqs - 1e6) / 1e6) * 0.15
gbs = objects * active_seconds_per_object * 0.128
dur_cost = math.ceil(max(0, gbs - 400_000) / 1e6) * 12.50
return reqs, gbs, req_cost, dur_cost, req_cost + dur_cost + 5
month = 60 * 60 * 24 * 30
# 100 rooms x 100 sockets, 1 msg/min each, 10 ms of JS per message => 1 s active per room per minute
hib = cost(100, 100, 1, 60 * 24 * 30 * 1)
std = cost(100, 100, 1, month) # standard WebSocket API: object pinned in memory all month
for name, (reqs, gbs, rc, dc, total) in [("hibernation API", hib), ("standard API", std)]:
print(f"{name:<16} billed reqs={reqs:>12,.0f} GB-s={gbs:>12,.0f} requests=${rc:>6.2f} duration=${dc:>7.2f} total=${total:>7.2f}/mo")
print(f"hibernation saves ${std[4] - hib[4]:.2f}/mo; docs Example 4 total = $20.65")Output (simulation):
hibernation API billed reqs= 21,610,000 GB-s= 552,960 requests=$ 3.15 duration=$ 12.50 total=$ 20.65/mo
standard API billed reqs= 21,610,000 GB-s= 33,177,600 requests=$ 3.15 duration=$ 412.50 total=$ 420.65/mo
hibernation saves $400.00/mo; docs Example 4 total = $20.65What happens if you choose otherwise
- Standard WebSocket API: same features, but every connected room is billed for wall-clock duration 24/7.
- Your own WebSocket servers plus Redis pub/sub: you need sticky routing, cross-node fan-out, presence that survives node loss, and capacity planning. See the scaling-WebSockets lesson for that design.
- Server-Sent Events or polling through a stateless Worker: fine for one-way feeds, but with no single place that orders writes you are back to an external store for ordering.
Pitfalls
- Calling
ws.accept()on a socket you passed toacceptWebSocket(). Use one API, not both. - Keeping per-user state in instance fields. It is gone after hibernation. Use attachments (16 KB) or SQLite.
- Using
setIntervalfor heartbeats. It prevents hibernation. Use protocol pings or an auto-response. - Expecting connections to survive deploys. Code updates disconnect all WebSockets, so clients need reconnect with backoff and a resume token.
- On compatibility dates before 2026-04-07 you must call
ws.close()inwebSocketCloseto avoid 1006 errors. Newer dates complete the close handshake for you.
Interview Q&A
How do you route every member of a chat room to the same server?
Answer
The Worker turns the room name into an ID with getByName, so every connection for that room lands on the one object that owns it. No sticky load balancer or pub/sub layer is needed.
How does hibernation work and what do you lose?
Answer
The runtime keeps the sockets open while the object is evicted from memory. A new frame recreates the object and calls webSocketMessage. You lose in-memory fields, so per-connection data goes in serializeAttachment and shared data goes in SQLite.
How would you implement presence?
Answer
Derive it from getWebSockets() and attachments, broadcast on join and close, and use tags for multi-tab users. It survives hibernation because it is rebuilt from the sockets.
How do you schedule 1,000 reminders in one object when there is only one alarm?
Answer
Store them in a table indexed by due time. alarm() processes everything due, then sets the alarm to the next due time. Handlers must be idempotent because alarms are at least once.
One room has 20,000 viewers. What changes?
Answer
Batch frames, move ephemeral signals off storage, and fan out through relay objects so no single object sends 20,000 frames per message. Consider SSE or a CDN-backed stream if the audience only reads.
What does WebSocket auto-response give you?
Answer
The runtime can answer a configured request such as a ping without waking the object, which keeps keepalives cheap.
CRDT or server ordering for collaborative editing in a room object?
Answer
Server ordering is simple because the object already serializes edits. CRDTs help when clients edit offline or across rooms and must merge without the server.
Why did the cost model show $20.65 vs $420.65?
Answer
Requests cost the same, but the standard API keeps the object awake and bills far more GB-seconds of duration.
Check yourself
Sketch the messages a room object handles for join, post, typing and leave. Mark which ones touch SQLite, which use attachments, and which stay ephemeral.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Scaling WebSockets — Sticky Sessions, Fan-out & Backpressure, WebSockets & MQTT — Real-Time Protocols for Senior Interviews, CRDTs — Conflict-Free Types, Convergence & When Consensus Wins, Thread-per-Connection vs Event Loop vs Async Tasks - Blocking the Loop, C10K & Backpressure, Browser Engines & PWAs — Service Workers, Rendering & Process Model.