1---
2title: Accept WebSockets
3description: Accept WebSocket connections in a Durable Object, broadcast between peers, and survive Cloudflare's hibernation.
4sidebar:
5 order: 8
6---
7
8The `Counter` Durable Object you added in the previous part holds
9state per key but only over plain HTTP. Now you'll build `Room` — a
10chat Durable Object that accepts WebSocket connections, broadcasts
11messages between peers, and **survives hibernation**.
12
13Cloudflare can evict an idle Durable Object from memory while
14keeping its WebSocket connections open. When a message arrives the
15DO is reconstructed from scratch, your constructor Effect runs again, and
16your `webSocketMessage` handler is invoked. Any per-instance state
17you held in JavaScript variables is gone — you have to put it back.
18
19## Accept a connection
20
21Start with the smallest possible Room — just enough to complete
22the WebSocket handshake. Create `src/room.ts`:
23
24```typescript
25// src/room.ts
26import * as Cloudflare from "alchemy/Cloudflare";
27import * as Effect from "effect/Effect";
28
29export default class Room extends Cloudflare.DurableObject<Room>()(
30 "Rooms",
31 Effect.gen(function* () {
32 return Effect.gen(function* () {
33 return {
34 fetch: Effect.gen(function* () {
35 const [response, socket] = yield* Cloudflare.upgrade();
36 return response;
37 }),
38 };
39 });
40 }),
41) {}
42```
43
44`Cloudflare.upgrade()` does two things in one call: it builds a
45`101 Switching Protocols` response with the right headers, and it
46hands you a `Socket` you can `send` / `close` /
47`serializeAttachment` on. Returning the response from `fetch` is
48what tells Cloudflare to hijack the TCP connection and complete the
49handshake.
50
51This compiles and accepts connections, but messages go nowhere yet.
52
53## Remember who's connected
54
55Add a `sessions` map and stash the new socket into it on every
56upgrade. Tag each socket with an id so we know which session sent
57what later:
58
59```diff lang="typescript"
60// src/room.ts
61import * as Cloudflare from "alchemy/Cloudflare";
62import * as Effect from "effect/Effect";
63
64export default class Room extends Cloudflare.DurableObject<Room>()(
65 "Rooms",
66 Effect.gen(function* () {
67 return Effect.gen(function* () {
68+ const sessions = new Map<string, Cloudflare.WebSocket>();
69
70 return {
71 fetch: Effect.gen(function* () {
72 const [response, socket] = yield* Cloudflare.upgrade();
73+ const id = crypto.randomUUID();
74+ socket.serializeAttachment({ id });
75+ sessions.set(id, socket);
76 return response;
77 }),
78 };
79 });
80 }),
81) {}
82```
83
84`socket.serializeAttachment({ id })` persists a structured-cloneable value
85**alongside the socket itself**. Cloudflare keeps it across
86hibernation, so when the DO wakes back up the socket still
87remembers which session it belongs to. We'll need that in the next
88two steps.
89
90## Handle incoming messages
91
92Add a `webSocketMessage` handler. Cloudflare invokes it every time
93a peer sends data; we look up the sender's session id from the
94attachment, prefix each message with a short label, and fan it out
95to everyone in the room:
96
97```diff lang="typescript"
98return {
99 fetch: Effect.gen(function* () {
100 const [response, socket] = yield* Cloudflare.upgrade();
101 const id = crypto.randomUUID();
102 socket.serializeAttachment({ id });
103 sessions.set(id, socket);
104 return response;
105 }),
106+ webSocketMessage: Effect.fn(function* (
107+ socket: Cloudflare.WebSocket,
108+ message: string | ArrayBuffer,
109+ ) {
110+ const attachment = socket.deserializeAttachment<{ id: string }>();
111+ if (!attachment) return;
112+ const text =
113+ typeof message === "string"
114+ ? message
115+ : new TextDecoder().decode(message);
116+ const label = attachment.id.slice(0, 8);
117+ for (const peer of sessions.values()) {
118+ yield* peer.send(`[${label}] ${text}`);
119+ }
120+ }),
121};
122```
123
124`Effect.fn` lets you write a generator function with
125arbitrary parameters. The Cloudflare runtime calls it with the
126socket and the inbound payload (which can be a string or an
127`ArrayBuffer` depending on how the client sent it).
128
129## Clean up on disconnect
130
131When a peer disconnects, drop them from the `sessions` map so we
132don't try to send to a dead socket:
133
134```diff lang="typescript"
135return {
136 fetch: /* ... */,
137 webSocketMessage: /* ... */,
138+ webSocketClose: Effect.fn(function* (
139+ ws: Cloudflare.WebSocket,
140+ code: number,
141+ reason: string,
142+ ) {
143+ const attachment = ws.deserializeAttachment<{ id: string }>();
144+ if (attachment) sessions.delete(attachment.id);
145+ yield* ws.close(code, reason);
146+ }),
147+ webSocketError: Effect.fn(function* (
148+ ws: Cloudflare.WebSocket,
149+ error: unknown,
150+ ) {
151+ // Cloudflare closes an errored socket after this handler runs; only
152+ // the session bookkeeping is ours to clean up.
153+ const attachment = ws.deserializeAttachment<{ id: string }>();
154+ if (attachment) sessions.delete(attachment.id);
155+ }),
156};
157```
158
159`webSocketError` fires when the connection fails instead of closing
160cleanly (a network reset, a protocol error). The runtime closes the
161socket afterwards, so the handler only has to drop the peer's state.
162
163## Restore sessions after hibernation
164
165Here's the catch. Cloudflare can evict an idle DO from memory
166while keeping its WebSockets open. When the next message arrives,
167the DO is reconstructed: the inner `Effect.gen` runs again, and
168your `sessions` map starts empty — but the open sockets are still
169there.
170
171Resolve `Cloudflare.DurableObjectState` in the **outer** Effect,
172then rehydrate the map at the top of the inner constructor by reading
173every attached socket back from the runtime:
174
175```diff lang="typescript"
176Effect.gen(function* () {
177+ const state = yield* Cloudflare.DurableObjectState;
178 return Effect.gen(function* () {
179 const sessions = new Map<string, Cloudflare.WebSocket>();
180
181+ for (const socket of yield* state.getWebSockets()) {
182+ const data = socket.deserializeAttachment<{ id: string }>();
183+ if (data) sessions.set(data.id, socket);
184+ }
185
186 return {
187 fetch: /* ... */,
188 webSocketMessage: /* ... */,
189 webSocketClose: /* ... */,
190 };
191 });
192});
193```
194
195`state.getWebSockets()` returns every still-attached socket on the
196current instance, and `deserializeAttachment` recovers the
197`{ id }` we stashed in the upgrade step. Without this loop the
198first message after hibernation would broadcast to nobody.
199
200:::note
201The `state` *reference* is resolved in the outer (Construction) Effect, but
202`state.getWebSockets()` is
203[colored with `RuntimeContext`](/infrastructure-as-effects/layers#the-types-hold-the-boundary),
204so it can only run in the inner (runtime) Effect — which is exactly
205where it needs to be, since it must re-run every time Cloudflare
206reconstructs the instance after hibernation.
207:::
208
209## Define the session data
210
211You can save session data on a WebSocket and validate it when reading it
212back—even after the Durable Object wakes from hibernation. First, define
213what a valid session contains:
214
215```typescript
216import * as Schema from "effect/Schema";
217
218const Session = Schema.Struct({
219 id: Schema.String,
220 joinedAt: Schema.DateFromString,
221});
222```
223
224`id` is a string. `joinedAt` is a `Date` in your code; this schema converts
225it to a string for storage and back to a `Date` when you read it.
226
227## Save session data on a socket
228
229Replace the `serializeAttachment` call in `fetch` with:
230
231```typescript
232const joinedAt = yield* Effect.sync(() => new Date());
233yield* socket.setAttachment(Session, { id, joinedAt });
234```
235
236This saves the session on this connection, so it survives hibernation.
237Cloudflare must be able to serialize the stored value, and the serialized
238attachment must fit within 16,384 bytes, including serialization overhead.
239
240## Read the session back
241
242```typescript
243const session = yield* socket.getAttachment(Session);
244
245session.id; // string
246session.joinedAt; // Date
247```
248
249`getAttachment` checks the saved data against `Session`. Missing or invalid
250data fails with `WebSocketAttachmentError` instead of returning an invalid
251session. The existing `serializeAttachment` and `deserializeAttachment`
252methods remain available if you do not want schema validation.
253
254## Validate attachments after hibernation
255
256Use `getAttachment` in the restoration loop to validate surviving sockets:
257
258```typescript
259for (const socket of yield* state.getWebSockets()) {
260 yield* socket.getAttachment(Session).pipe(
261 Effect.tap((session) =>
262 Effect.sync(() => sessions.set(session.id, socket)),
263 ),
264 Effect.catchTag("WebSocketAttachmentError", () =>
265 socket.close(1008, "Invalid session"),
266 ),
267 );
268}
269```
270
271This example closes connections whose sessions are missing or invalid,
272including older sessions without `joinedAt`. Your code chooses this behavior;
273`getAttachment` does not close the connection for you.
274
275For diagnostics, `WebSocketAttachmentError.reason` identifies `encode`,
276`decode`, `missing`, `read`, or `write`; `cause` contains the original error.
277A stored `null` or `undefined` counts as missing.
278
279## Choose an attachment error policy
280
281A handler can instead ignore an invalid attachment:
282
283```typescript
284const session = yield* socket.getAttachment(Session).pipe(
285 Effect.catchTag("WebSocketAttachmentError", () =>
286 Effect.succeed(undefined),
287 ),
288);
289if (session === undefined) return;
290```
291
292Omit `catchTag` to propagate the typed failure, or handle it at your normal
293Effect boundary. These helpers are for application-owned attachments, not
294the internal serializer metadata of WebSocket RPC connections.
295
296## Extract a broadcast helper
297
298Before wiring this up, pull the broadcast loop into a reusable
299`broadcast` function — we'll need it again when we add scheduled
300reminders below:
301
302```diff lang="typescript"
303Effect.gen(function* () {
304 const state = yield* Cloudflare.DurableObjectState;
305 return Effect.gen(function* () {
306 const sessions = new Map<string, Cloudflare.WebSocket>();
307
308 for (const socket of yield* state.getWebSockets()) {
309 const data = socket.deserializeAttachment<{ id: string }>();
310 if (data) sessions.set(data.id, socket);
311 }
312
313+ const broadcast = (text: string) =>
314+ Effect.gen(function* () {
315+ for (const peer of sessions.values()) {
316+ yield* peer.send(text);
317+ }
318+ });
319
320 return {
321 fetch: /* ... */,
322 webSocketMessage: Effect.fn(function* (socket, message) {
323 const attachment = socket.deserializeAttachment<{ id: string }>();
324 if (!attachment) return;
325 const text = typeof message === "string"
326 ? message
327 : new TextDecoder().decode(message);
328- for (const peer of sessions.values()) {
329- yield* peer.send(`[${attachment.id.slice(0, 8)}] ${text}`);
330- }
331+ yield* broadcast(`[${attachment.id.slice(0, 8)}] ${text}`);
332 }),
333 webSocketClose: /* ... */,
334+ broadcast,
335 };
336 });
337});
338```
339
340Returning `broadcast` from the inner Effect makes it callable as an
341RPC method — handy when other resources (a Workflow, a Container)
342want to push messages into the room.
343
344## Wire the Room into the Worker
345
346The Worker forwards any `Upgrade: websocket` request on `/room/:id`
347to the matching DO instance:
348
349```diff lang="typescript"
350// src/worker.ts
351import * as Cloudflare from "alchemy/Cloudflare";
352import * as Effect from "effect/Effect";
353+import { HttpServerRequest } from "effect/http/HttpServerRequest";
354import * as HttpServerResponse from "effect/http/HttpServerResponse";
355+import Room from "./room.ts";
356
357export default Cloudflare.Worker(
358 "Worker",
359 { main: import.meta.url },
360 Effect.gen(function* () {
361+ const rooms = yield* Room;
362
363 return {
364 fetch: Effect.gen(function* () {
365+ const request = yield* HttpServerRequest;
366+
367+ if (request.url.startsWith("/room/")) {
368+ if (request.headers.upgrade !== "websocket") {
369+ return HttpServerResponse.text(
370+ "Expected Upgrade: websocket",
371+ { status: 426 },
372+ );
373+ }
374+ const id = request.url.split("/").pop()!;
375+ return yield* rooms.getByName(id).fetch(request);
376+ }
377+
378 return HttpServerResponse.text("Hello from my Worker!");
379 }),
380 };
381 }),
382);
383```
384
385`rooms.getByName(id).fetch(request)` is the typed equivalent of
386"forward this HTTP request to the DO". Because the DO returned a
387`fetch` field with the upgrade response, the runtime hijacks the
388TCP connection — the Worker's response **is** the WebSocket
389handshake.
390
391## Deploy
392
393```sh
394bun alchemy deploy
395```
396
397## Connect from a test
398
399The integration test connects to the deployed Worker over a real
400WebSocket using Effect's native [`Socket`](https://effect.website/docs/platform/socket/)
401module. Unlike the raw `WebSocket` global, `Socket` is fully
402Effect-aware: the connection lifecycle is tied to a `Scope`,
403incoming frames flow through a `Queue`, and the open handshake is
404signalled with a `Deferred`.
405
406Start the test file with the imports and a `stack` deployed once
407per run:
408
409```typescript
410// test/integ.test.ts
411import * as Cloudflare from "alchemy/Cloudflare";
412import * as Test from "alchemy/Test/Bun";
413import { expect } from "bun:test";
414import * as Deferred from "effect/Deferred";
415import * as Effect from "effect/Effect";
416import * as Queue from "effect/Queue";
417import * as Socket from "effect/socket/Socket";
418import Stack from "../alchemy.run.ts";
419
420const { test, beforeAll, deploy } = Test.make({
421 providers: Cloudflare.providers(),
422 state: Cloudflare.state(),
423});
424
425const stack = beforeAll(deploy(Stack));
426```
427
428`beforeAll(deploy(Stack))` deploys your Worker + Room once and
429exposes the result (including the public `url`) as an `Effect`
430the tests can `yield*`.
431
432## Open the socket and acquire a writer
433
434We want each test to say "give me a connected client" and get back
435something with `send` and `next` methods. Start by opening the
436socket and grabbing a writer for sending frames:
437
438```typescript
439const connect = (url: string) =>
440 Effect.gen(function* () {
441 const socket = yield* Socket.makeWebSocket(url);
442 const send = yield* socket.writer;
443
444 return {
445 send: (msg: string) => send(msg),
446 };
447 });
448```
449
450`Socket.makeWebSocket(url)` builds a `Socket` resource. It needs a
451`WebSocketConstructor` in context, which we'll provide at the test
452boundary. `socket.writer` returns a function for sending frames —
453acquiring it once is more efficient than re-deriving it per send.
454
455## Drain inbound frames into a queue
456
457To let the test consume incoming messages, push each frame into an
458unbounded `Queue` and expose a `next` accessor that pulls one off:
459
460```diff lang="typescript"
461const connect = (url: string) =>
462 Effect.gen(function* () {
463 const socket = yield* Socket.makeWebSocket(url);
464+ const messages = yield* Queue.unbounded<string>();
465 const send = yield* socket.writer;
466
467+ yield* Effect.forkScoped(
468+ socket.runString((msg) => Queue.offer(messages, msg)),
469+ );
470+
471 return {
472 send: (msg: string) => send(msg),
473+ next: Queue.take(messages),
474 };
475 });
476```
477
478`socket.runString` consumes inbound text frames and hands each one
479to your callback. `Effect.forkScoped` runs the read loop in the
480background, tied to the surrounding scope — when the test scope
481closes, the loop is interrupted and the socket is closed.
482
483## Wait for the open handshake
484
485Returning before the socket is actually open lets the caller race
486the handshake — `send` could fire before the server is ready. Block
487on a `Deferred` that the `onOpen` hook fulfils:
488
489```diff lang="typescript"
490const connect = (url: string) =>
491 Effect.gen(function* () {
492 const socket = yield* Socket.makeWebSocket(url);
493 const messages = yield* Queue.unbounded<string>();
494 const send = yield* socket.writer;
495+ const opened = yield* Deferred.make<void>();
496
497 yield* Effect.forkScoped(
498- socket.runString((msg) => Queue.offer(messages, msg)),
499+ socket.runString((msg) => Queue.offer(messages, msg), {
500+ onOpen: Deferred.succeed(opened, undefined),
501+ }),
502 );
503+ yield* Deferred.await(opened);
504
505 return {
506 send: (msg: string) => send(msg),
507 next: Queue.take(messages),
508 };
509 });
510```
511
512`Deferred.await(opened)` blocks the helper until `onOpen` fires, so
513the caller can `send` immediately on return without racing the
514handshake.
515
516## Write the test body
517
518Open two clients to the same room, have one send `"hello bob"`,
519and assert the other receives the broadcast. Wrap the whole effect
520in `Effect.scoped` so the test scope closes when it finishes:
521
522```typescript
523test(
524 "Room broadcasts messages between peers",
525 Effect.gen(function* () {
526 const { url } = yield* stack;
527 const wsUrl = url.replace(/^http/, "ws") + "/room/test";
528
529 const alice = yield* connect(wsUrl);
530 const bob = yield* connect(wsUrl);
531
532 yield* alice.send("hello bob");
533
534 const received = yield* bob.next;
535 expect(received).toMatch(/hello bob$/);
536 }).pipe(Effect.scoped),
537 { timeout: 30_000 },
538);
539```
540
541`Effect.scoped` closes the scope at the end of the test, which
542interrupts every forked read loop in `connect` and closes both
543sockets.
544
545## Provide the WebSocket constructor
546
547`Socket.makeWebSocket` doesn't know how to actually open a
548connection by itself — it depends on a `WebSocketConstructor`
549service. Provide the global one:
550
551```diff lang="typescript"
552test(
553 "Room broadcasts messages between peers",
554 Effect.gen(function* () {
555 // ...
556 }).pipe(
557 Effect.scoped,
558+ Effect.provide(Socket.layerWebSocketConstructorGlobal),
559 ),
560 { timeout: 30_000 },
561);
562```
563
564`layerWebSocketConstructorGlobal` supplies the runtime's global
565`WebSocket` constructor. On Bun and Node 22+ this picks up the
566built-in `WebSocket`.
567
568Run it:
569
570```sh
571bun test test/integ.test.ts
572```
573
574Alice's `"hello bob"` arrives at the Room as a `webSocketMessage`,
575the broadcast loop sends `"[<alice-id>] hello bob"` to every peer,
576and Bob's `next` resolves with it from the queue.
577
578## Register a reminder callback
579
580Durable Objects have **alarms** — single-shot timers backed by the
581DO's storage. `Alchemy.makeCallback` uses SQLite and native alarms to
582persist typed jobs across hibernation. Register a reminder handler in
583the inner Effect, after restoring sessions and defining `broadcast`:
584
585```diff lang="typescript"
586// src/room.ts
587+import * as Alchemy from "alchemy";
588
589// Inside the inner Effect, after defining broadcast:
590+const onReminder = yield* Alchemy.makeCallback(
591+ "reminder",
592+ Effect.fn(function* (payload: { message: string }) {
593+ yield* broadcast(`[reminder] ${payload.message}`);
594+ }),
595+);
596```
597
598The inner Effect registers this handler again after each hibernation.
599Alchemy invokes it when a persisted job is due, so this example does
600not need a custom `alarm` handler.
601
602## Schedule a future broadcast (alarms)
603
604Recognise `/remind <seconds> <message>` inside `webSocketMessage`
605and schedule a job using the callback handle:
606
607```diff lang="typescript"
608webSocketMessage: Effect.fn(function* (socket, message) {
609 const attachment = socket.deserializeAttachment<{ id: string }>();
610 if (!attachment) return;
611 const text = typeof message === "string"
612 ? message
613 : new TextDecoder().decode(message);
614
615+ const remindMatch = text.match(/^\/remind\s+(\d+)\s+(.+)$/);
616+ if (remindMatch) {
617+ const delaySec = parseInt(remindMatch[1], 10);
618+ const reminder = remindMatch[2];
619+ const id = yield* Effect.sync(() => crypto.randomUUID());
620+ yield* onReminder.schedule(id, {
621+ after: `${delaySec} seconds`,
622+ payload: { message: reminder },
623+ });
624+ yield* socket.send(`[system] Reminder scheduled in ${delaySec}s`);
625+ return;
626+ }
627
628 yield* broadcast(`[${attachment.id.slice(0, 8)}] ${text}`);
629}),
630```
631
632Each random ID creates an independent reminder in this Room. Reusing
633an ID would replace its pending reminder; `onReminder.cancel(id)`
634cancels it. The payload is typed by the registered handler.
635
636:::caution[Broadcasts can repeat]
637Callback delivery is at least once. If a broadcast reaches some peers
638and then fails, a retry can send the reminder again. For deduplicated
639messages, include a stable message ID and have clients ignore IDs they
640have already received. This example delivers to the peers connected
641when it runs; it does not queue messages for disconnected users.
642:::
643
644If you previously deployed this tutorial using `scheduleEvent`, keep
645your existing `alarm` handler and `processScheduledEvents` call until
646those jobs drain. Old rows are preserved, not converted to callbacks.
647See [upgrading existing schedules](/cloudflare/compute/durable-objects#upgrade-existing-schedules)
648and the [native alarm retry limitation](/cloudflare/compute/durable-objects#retry-failed-callbacks).
649
650## Verify the reminder
651
652Add a test that sends `/remind 1 hello` and waits for the callback's
653broadcast:
654
655```typescript
656// test/integ.test.ts (additions)
657test(
658 "Room schedules a reminder broadcast",
659 Effect.gen(function* () {
660 const { url } = yield* stack;
661 const wsUrl = url.replace(/^http/, "ws") + "/room/reminders";
662
663 const peer = yield* connect(wsUrl);
664 yield* peer.send("/remind 1 hello");
665
666 // First message back is the [system] confirmation;
667 // the second is the [reminder] broadcast a second later.
668 yield* peer.next;
669 const reminder = yield* peer.next;
670 expect(reminder).toContain("[reminder] hello");
671 }).pipe(
672 Effect.scoped,
673 Effect.provide(Socket.layerWebSocketConstructorGlobal),
674 ),
675 { timeout: 30_000 },
676);
677```
678
679You now have an interesting backend. Next you'll
680[add a React SPA](/cloudflare/frontend/vite-spa) so users have a UI
681to talk to it — frontend and backend deploying together from the
682same `alchemy.run.ts`.