Skip to content

Commit 740cd36

Browse files
anonrigkentonv
andauthored
add changelog for web_socket_auto_reply_to_close (#28887)
* add changelog for web_socket_auto_reply_to_close * Apply suggestions from code review Co-authored-by: Kenton Varda <kenton@cloudflare.com> * replace changelog with compat-flag entry * update websocket/do documentation --------- Co-authored-by: Kenton Varda <kenton@cloudflare.com>
1 parent 6ebe319 commit 740cd36

11 files changed

Lines changed: 220 additions & 43 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
_build:
3+
publishResources: false
4+
render: never
5+
list: never
6+
7+
name: "WebSocket auto-reply to close"
8+
sort_date: "2026-03-10"
9+
enable_date: "2026-04-07"
10+
enable_flag: "web_socket_auto_reply_to_close"
11+
disable_flag: "web_socket_manual_reply_to_close"
12+
---
13+
14+
When a server sends a WebSocket Close frame, the Workers runtime now automatically sends a reciprocal Close frame and transitions `readyState` to `CLOSED` before firing the `close` event. This matches the [WebSocket spec](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event) and browser behavior.
15+
16+
Previously, receiving a server-initiated Close frame left the WebSocket in `CLOSING` and required the application to call `close()` itself. With this flag active, you no longer need to call `close()` in your `close` event handler. The runtime handles the close handshake automatically.
17+
18+
```js
19+
const [client, server] = Object.values(new WebSocketPair());
20+
server.accept();
21+
22+
server.addEventListener("close", (event) => {
23+
// readyState is already CLOSED — no need to call server.close().
24+
console.log(server.readyState); // WebSocket.CLOSED
25+
console.log(event.code); // 1000
26+
console.log(event.wasClean); // true
27+
}, { once: true });
28+
```
29+
30+
If you do still call `close()` inside the handler, the call is silently ignored. This means existing code that manually replies to Close frames will not break when you update your compatibility date.
31+
32+
The automatic close behavior can interfere with WebSocket proxying. When a Worker proxies between a client and a backend, the old behavior allowed the Worker to observe a backend Close frame without the runtime tearing down the connection, giving the Worker time to coordinate a clean close on the client side. To support this pattern, the `accept()` method now accepts an option `allowHalfOpen`. Call `ws.accept({ allowHalfOpen: true })` to restore the old half-open behavior regardless of the compatibility flag.
33+
34+
```js
35+
const [client, server] = Object.values(new WebSocketPair());
36+
37+
// Opt into half-open mode for proxying
38+
server.accept({ allowHalfOpen: true });
39+
40+
server.addEventListener("close", (event) => {
41+
// With allowHalfOpen true, readyState is still CLOSING here,
42+
// giving you time to coordinate the close on the other side.
43+
console.log(server.readyState); // WebSocket.CLOSING
44+
45+
// Manually close when ready.
46+
server.close(1000, "done");
47+
}, { once: true });
48+
```
49+
50+
Note that there is no corresponding option to the `WebSocket` constructor. WebSockets constructed with `new WebSocket` will always auto-reply to closes after this flag takes effect. WebSockets constructed this way are automatically "accepted", so there is no opportunity to pass the option to `accept()`. If you are creating a WebSocket with `new WebSocket`, but you need half-open behavior, you will need to switch to using `fetch()` instead.
51+
52+
```js
53+
// This does not allow half-open:
54+
let ws = new WebSocket("wss://example.com");
55+
56+
// But you can do this instead:
57+
let resp = await fetch("https://example.com", {
58+
headers: { "Upgrade": "websocket" }
59+
});
60+
if (!resp.webSocket) {
61+
throw new Error("WebSocket handshake not accepted");
62+
}
63+
let ws = resp.webSocket;
64+
ws.accept({ allowHalfOpen: true });
65+
```
66+
67+
For more information, refer to the [WebSocket API documentation](/workers/runtime-apis/websockets/).

src/content/docs/durable-objects/api/base.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,9 @@ export class MyDurableObject extends DurableObject<Env> {
175175
reason <Type text="string" />, wasClean <Type text="boolean" />)
176176
</code>
177177
: <Type text="void" /> | <Type text="Promise<void>" />- Called by the system
178-
when a WebSocket connection is closed. - You **must** call `ws.close(code,
179-
reason)` inside this handler to complete the WebSocket close handshake.
180-
Failing to reciprocate the close will result in `1006` errors on the client,
181-
representing an abnormal closure per the WebSocket specification.
178+
when a WebSocket connection is closed.
179+
- With the [`web_socket_auto_reply_to_close`](/workers/configuration/compatibility-flags/#websocket-auto-reply-to-close) compatibility flag (enabled by default on compatibility dates on or after `2026-04-07`), the runtime automatically sends a reciprocal Close frame and transitions `readyState` to `CLOSED` before this handler is called. You do not need to call `ws.close()` — but doing so is safe (the call is silently ignored).
180+
- On older compatibility dates (before `2026-04-07`), you **must** call `ws.close(code, reason)` inside this handler to complete the WebSocket close handshake. Failing to reciprocate the close will result in `1006` errors on the client, representing an abnormal closure per the WebSocket specification.
182181
- This method can be `async`.
183182

184183
#### Parameters
@@ -198,7 +197,9 @@ export class MyDurableObject extends DurableObject<Env> {
198197
```ts
199198
export class MyDurableObject extends DurableObject<Env> {
200199
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
201-
// Complete the WebSocket close handshake
200+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07),
201+
// the runtime has already completed the close handshake.
202+
// On older compat dates, call ws.close(code, reason) here.
202203
ws.close(code, reason);
203204
console.log(`WebSocket closed: code=${code}, reason=${reason}`);
204205
}

src/content/docs/durable-objects/api/state.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,9 @@ The WebSocket Hibernation API permits a maximum of 32,768 WebSocket connections
190190

191191
:::note[`waitUntil` is not necessary]
192192

193-
Disconnected WebSockets are not returned by this method, but `getWebSockets` may still return WebSockets even after `ws.close` has been called. For example, if the server-side WebSocket sends a close, but does not receive one back (and has not detected a disconnect from the client), then the connection is in the CLOSING 'readyState'. The client might send more messages, so the WebSocket is technically not disconnected.
193+
Disconnected WebSockets are not returned by this method, but `getWebSockets` may still return WebSockets even after `ws.close` has been called. For example, if the server-side WebSocket sends a close, but does not receive one back (and has not detected a disconnect from the client), then the connection is in the `CLOSING` readyState. The client might send more messages, so the WebSocket is technically not disconnected.
194+
195+
With the [`web_socket_auto_reply_to_close`](/workers/configuration/compatibility-flags/#websocket-auto-reply-to-close) compatibility flag (enabled by default on compatibility dates on or after `2026-04-07`), the runtime automatically completes the close handshake, so WebSockets transition from `CLOSING` to `CLOSED` much faster and are less likely to be observed in the `CLOSING` state.
194196

195197
:::
196198

src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,7 +1185,8 @@ export class ChatRoom extends DurableObject<Env> {
11851185
reason: string,
11861186
wasClean: boolean
11871187
) {
1188-
// Calling close() completes the WebSocket handshake
1188+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
1189+
// auto-replies to Close frames. Calling close() is safe but no longer required.
11891190
ws.close(code, reason);
11901191
console.log(`WebSocket closed: ${code} ${reason}`);
11911192
}
@@ -1204,7 +1205,7 @@ With the Hibernation API, your Durable Object can go to sleep when there is no a
12041205
Best practices:
12051206

12061207
- The [WebSocket Hibernation API](/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api) exposes `webSocketError`, `webSocketMessage`, and `webSocketClose` handlers for their respective WebSocket events.
1207-
- When implementing `webSocketClose`, you **must** reciprocate the close by calling `ws.close()` to avoid swallowing the WebSocket close frame. Failing to do so results in `1006` errors, representing an abnormal close per the WebSocket specification.
1208+
- With the [`web_socket_auto_reply_to_close`](/workers/configuration/compatibility-flags/#websocket-auto-reply-to-close) compatibility flag (enabled by default on compatibility dates on or after `2026-04-07`), the runtime automatically completes the close handshake. Calling `ws.close()` in `webSocketClose` is still safe but no longer required. On older compatibility dates, you **must** call `ws.close()` to avoid `1006` abnormal closure errors.
12081209

12091210
Refer to [WebSockets](/durable-objects/best-practices/websockets/) for more details.
12101211

@@ -1275,7 +1276,8 @@ export class ChatRoom extends DurableObject<Env> {
12751276
}
12761277

12771278
async webSocketClose(ws: WebSocket, code: number, reason: string) {
1278-
// Calling close() completes the WebSocket handshake
1279+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
1280+
// auto-replies to Close frames. Calling close() is safe but no longer required.
12791281
ws.close(code, reason);
12801282
const state = ws.deserializeAttachment() as ConnectionState;
12811283
this.broadcast(`${state.username} left the chat`);

src/content/docs/durable-objects/best-practices/websockets.mdx

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ export class WebSocketHibernationServer extends DurableObject {
100100
}
101101

102102
async webSocketClose(ws, code, reason, wasClean) {
103-
// Calling close() on the server completes the WebSocket close handshake
103+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
104+
// auto-replies to Close frames. Calling close() is safe but no longer required.
104105
ws.close(code, reason);
105106
}
106107
}
@@ -148,7 +149,8 @@ export class WebSocketHibernationServer extends DurableObject {
148149
reason: string,
149150
wasClean: boolean,
150151
) {
151-
// Calling close() on the server completes the WebSocket close handshake
152+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
153+
// auto-replies to Close frames. Calling close() is safe but no longer required.
152154
ws.close(code, reason);
153155
}
154156
}
@@ -190,7 +192,8 @@ self.ctx = state
190192
)
191193

192194
async def webSocketClose(self, ws, code, reason, was_clean):
193-
# Calling close() on the server completes the WebSocket close handshake
195+
# With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
196+
# auto-replies to Close frames. Calling close() is safe but no longer required.
194197
ws.close(code, reason)
195198

196199
````
@@ -359,33 +362,34 @@ export class WebSocketServer extends DurableObject<Env> {
359362
const url = new URL(request.url);
360363
const orderId = url.searchParams.get("orderId") ?? "anonymous";
361364

362-
const webSocketPair = new WebSocketPair();
363-
const [client, server] = Object.values(webSocketPair);
364-
365-
this.ctx.acceptWebSocket(server);
365+
const webSocketPair = new WebSocketPair();
366+
const [client, server] = Object.values(webSocketPair);
366367

367-
// Persist per-connection state that survives hibernation
368-
const state: ConnectionState = {
369-
orderId,
370-
joinedAt: Date.now(),
371-
};
372-
server.serializeAttachment(state);
368+
this.ctx.acceptWebSocket(server);
373369

374-
return new Response(null, { status: 101, webSocket: client });
375-
}
370+
// Persist per-connection state that survives hibernation
371+
const state: ConnectionState = {
372+
orderId,
373+
joinedAt: Date.now(),
374+
};
375+
server.serializeAttachment(state);
376376

377-
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
378-
// Restore state after potential hibernation
379-
const state = ws.deserializeAttachment() as ConnectionState;
380-
ws.send(`Hello ${state.orderId}, you joined at ${state.joinedAt}`);
381-
}
377+
return new Response(null, { status: 101, webSocket: client });
378+
}
382379

383-
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
384-
const state = ws.deserializeAttachment() as ConnectionState;
385-
console.log(`${state.orderId} disconnected`);
386-
ws.close(code, reason);
387-
}
380+
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
381+
// Restore state after potential hibernation
382+
const state = ws.deserializeAttachment() as ConnectionState;
383+
ws.send(`Hello ${state.orderId}, you joined at ${state.joinedAt}`);
384+
}
388385

386+
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
387+
const state = ws.deserializeAttachment() as ConnectionState;
388+
console.log(`${state.orderId} disconnected`);
389+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
390+
// auto-replies to Close frames. Calling close() is safe but no longer required.
391+
ws.close(code, reason);
392+
}
389393
}
390394

391395
````
@@ -568,7 +572,9 @@ export class WebSocketServer extends DurableObject {
568572
);
569573
});
570574

571-
// If the client closes the connection, the runtime will close the connection too.
575+
// When the client closes the connection, clean up the server side.
576+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
577+
// auto-replies to Close frames. Calling close() is safe but no longer required.
572578
server.addEventListener("close", (cls) => {
573579
this.currentlyConnectedWebSockets -= 1;
574580
server.close(cls.code, "Durable Object is closing WebSocket");
@@ -612,7 +618,9 @@ export class WebSocketServer extends DurableObject {
612618
);
613619
});
614620

615-
// If the client closes the connection, the runtime will close the connection too.
621+
// When the client closes the connection, clean up the server side.
622+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
623+
// auto-replies to Close frames. Calling close() is safe but no longer required.
616624
server.addEventListener("close", (cls: CloseEvent) => {
617625
this.currentlyConnectedWebSockets -= 1;
618626
server.close(cls.code, "Durable Object is closing WebSocket");
@@ -657,7 +665,9 @@ self.currently_connected_websockets = 0
657665

658666
server.addEventListener("message", create_proxy(on_message))
659667

660-
# If the client closes the connection, the runtime will close the connection too.
668+
# When the client closes the connection, clean up the server side.
669+
# With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
670+
# auto-replies to Close frames. Calling close() is safe but no longer required.
661671
def on_close(event):
662672
self.currently_connected_websockets -= 1
663673
server.close(event.code, "Durable Object is closing WebSocket")

src/content/docs/durable-objects/examples/websocket-hibernation-server.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ export class WebSocketHibernationServer extends DurableObject {
151151
}
152152

153153
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
154-
// Calling close() on the server completes the WebSocket close handshake
154+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
155+
// auto-replies to Close frames. Calling close() is safe but no longer required.
155156
ws.close(code, reason);
156157
this.sessions.delete(ws);
157158
}
@@ -266,7 +267,8 @@ class WebSocketHibernationServer(DurableObject):
266267
session.ws.send(f"[Durable Object] message: {message}, from: {session_id}, to: all clients except the initiating client. Total connections: {len(self.sessions)}")
267268

268269
async def webSocketClose(self, ws, code, reason, wasClean):
269-
# Calling close() on the server completes the WebSocket close handshake
270+
# With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
271+
# auto-replies to Close frames. Calling close() is safe but no longer required.
270272
ws.close(code, reason)
271273
# Get the session ID from the WebSocket attachment to remove it from sessions
272274
session_id = ws.deserializeAttachment()

src/content/docs/durable-objects/examples/websocket-server.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export class WebSocketServer extends DurableObject {
9696
this.handleWebSocketMessage(server, event.data);
9797
});
9898

99-
// If the client closes the connection, the runtime will close the connection too.
99+
// When the client closes the connection, clean up the server side.
100100
server.addEventListener('close', () => {
101101
this.handleConnectionClose(server);
102102
});
@@ -130,6 +130,8 @@ export class WebSocketServer extends DurableObject {
130130

131131
async handleConnectionClose(ws: WebSocket) {
132132
this.sessions.delete(ws);
133+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
134+
// auto-replies to Close frames. Calling close() is safe but no longer required.
133135
ws.close(1000, 'Durable Object is closing WebSocket');
134136
}
135137
}
@@ -201,7 +203,7 @@ class WebSocketServer(DurableObject):
201203
message_proxy = create_proxy(on_message)
202204
server.addEventListener('message', message_proxy)
203205

204-
# If the client closes the connection, the runtime will close the connection too.
206+
# When the client closes the connection, clean up the server side.
205207
async def on_close(event):
206208
await self.handleConnectionClose(id)
207209
# Clean up proxies
@@ -236,6 +238,8 @@ class WebSocketServer(DurableObject):
236238
async def handleConnectionClose(self, session_id):
237239
session = self.sessions.pop(session_id, None)
238240
if session:
241+
# With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
242+
# auto-replies to Close frames. Calling close() is safe but no longer required.
239243
session.ws.close(1000, 'Durable Object is closing WebSocket')
240244
```
241245

src/content/docs/workers/best-practices/workers-best-practices.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,8 @@ export class ChatRoom extends DurableObject {
569569
reason: string,
570570
wasClean: boolean,
571571
) {
572+
// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
573+
// auto-replies to Close frames. Calling close() is safe but no longer required.
572574
ws.close(code, reason);
573575
}
574576
}

src/content/docs/workers/examples/websockets.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,8 @@ async function websocket(url) {
329329

330330
// Call accept() to indicate that you'll be handling the socket here
331331
// in JavaScript, as opposed to returning it on to a client.
332+
// You can pass { allowHalfOpen: true } if you need to coordinate
333+
// the close handshake manually (for example, when proxying).
332334
ws.accept();
333335

334336
// Now you can send and receive messages like before.
@@ -339,6 +341,14 @@ async function websocket(url) {
339341
}
340342
```
341343

344+
## WebSocket close behavior
345+
346+
With the [`web_socket_auto_reply_to_close`](/workers/configuration/compatibility-flags/#websocket-auto-reply-to-close) compatibility flag (enabled by default on compatibility dates on or after `2026-04-07`), the Workers runtime automatically replies to incoming Close frames and transitions `readyState` to `CLOSED` before firing the `close` event. You do not need to call `close()` in your `close` event handler, but doing so is safe (the call is silently ignored).
347+
348+
If you need half-open behavior (for example, for WebSocket proxying), pass `{ allowHalfOpen: true }` to `accept()`. Note that `new WebSocket(url)` always auto-replies after this flag takes effect. To get half-open behavior for a client WebSocket, use the `fetch()`-based pattern shown above and call `ws.accept({ allowHalfOpen: true })`.
349+
350+
For more details, refer to [WebSocket close behavior](/workers/runtime-apis/websockets/#close-behavior).
351+
342352
## WebSocket compression
343353

344354
Cloudflare Workers supports WebSocket compression. Refer to [WebSocket Compression](/workers/configuration/compatibility-flags/#websocket-compression) for more information.

src/content/docs/workers/observability/errors.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ You can prevent this by enforcing the [`no-floating-promises` eslint rule](https
6969

7070
If a WebSocket is missing the proper code to close its server-side connection, the Workers runtime will throw a `script will never generate a response` error. In the example below, the `'close'` event from the client is not properly handled by calling `server.close()`, and the error is thrown. In order to avoid this, ensure that the WebSocket's server-side connection is properly closed via an event listener or other server-side logic.
7171

72+
:::note
73+
74+
With the [`web_socket_auto_reply_to_close`](/workers/configuration/compatibility-flags/#websocket-auto-reply-to-close) compatibility flag (enabled by default on compatibility dates on or after `2026-04-07`), the runtime automatically completes the WebSocket close handshake. This specific error scenario is less likely to occur because the runtime handles the close for you. The example below applies to Workers on older compatibility dates.
75+
76+
:::
77+
7278
```js null {10}
7379
async function handleRequest(request) {
7480
let webSocketPair = new WebSocketPair();

0 commit comments

Comments
 (0)