WebSockets
Add real-time, two-way connections with typed WebSocket routes. Create a .ws.ts route to send typed messages between your server and connected clients. Each route instance gets its own Durable Object for shared state, and WebSocket routes need the Cloudflare target.
On this pageDefine a room or socket route · Connect from the browser · Send typed messages
SDK apps only
Typed WebSocket routes currently work in native SDK apps. They aren't available in meta-framework mode yet.
For example, /rooms/[id] gives each chat room its own instance. Use it for chat, presence, collaborative documents, or notifications.
Route files
This step creates a WebSocket route. Add a file with the .ws.ts suffix in routes/.
routes/
chat/[room].ws.ts
notifications.ws.tsFilename rules match regular server routes:
index.ws.tsbecomes the parent path[id].ws.tsbecomes:id[...slug].ws.tsbecomes a catch-all- route groups like
(marketing)/chat.ws.tsare ignored in the URL chat.dev.ws.ts/chat.prod.ws.tsrestrict the route to one environment
Put the environment suffix before .ws. chat.ws.dev.ts is not recognized.
defineRoom()
Use defineRoom() when clients should share a room or document identified by the route.
// routes/chat/[room].ws.ts
import * as v from 'valibot';
import { defineRoom } from 'void/ws';
const ClientMessage = v.variant('type', [
v.object({ type: v.literal('chat.message'), text: v.string() }),
]);
const ServerMessage = v.variant('type', [
v.object({
type: v.literal('chat.message'),
id: v.string(),
text: v.string(),
userId: v.string(),
}),
v.object({ type: v.literal('chat.joined'), userId: v.string() }),
]);
export default defineRoom({
messages: {
client: ClientMessage,
server: ServerMessage,
},
onBeforeConnect(ctx) {
if (!ctx.user) {
return new Response('Unauthorized', { status: 401 });
}
},
async onConnect(ctx) {
await ctx.room.broadcast({ type: 'chat.joined', userId: ctx.user!.id }, [ctx.connection.id]);
},
async onMessage(ctx, event) {
await ctx.room.broadcast({
type: 'chat.message',
id: crypto.randomUUID(),
text: event.text,
userId: ctx.user!.id,
});
},
});defineRoom() adds room helpers to the hook context:
ctx.room.broadcast(event, excludeIds?)ctx.room.getConnections()ctx.room.getConnection(id)ctx.connection.send(event)ctx.connection.close(code?, reason?)ctx.connection.setState(data)
defineWebSocket()
Use defineWebSocket() when each connection stands alone, with no shared room.
// routes/notifications.ws.ts
import * as v from 'valibot';
import { defineWebSocket } from 'void/ws';
export default defineWebSocket({
messages: {
client: v.object({ type: v.literal('notifications.ack'), id: v.string() }),
server: v.object({ type: v.literal('notifications.item'), title: v.string() }),
},
onBeforeConnect(ctx) {
if (!ctx.user) {
return new Response('Unauthorized', { status: 401 });
}
},
async onConnect(ctx) {
await ctx.socket.send({ type: 'notifications.item', title: 'Connected' });
},
});Client
This step connects from the browser with connect() from void/ws.
import { connect } from 'void/ws';
const socket = connect('/chat/:room', {
params: { room: 'general' },
});
socket.on('message', (event) => {
if (event.type === 'chat.message') {
console.log(event.text);
}
});
socket.send({ type: 'chat.message', text: 'hello' });connect() resolves relative URLs against the current origin and picks ws: or wss:. It buffers messages until the socket opens. It reconnects by default.
Typed messages
This step defines schemas for client and server messages.
messages.clientvalidates what the browser may sendmessages.servervalidates what the server may sendonMessage()receives the parsed, validated client eventctx.room.broadcast(),ctx.connection.send(), andctx.socket.send()are typed frommessages.serverconnect()infers route params, outgoing client messages, and incoming server messages from generated route types
Messages are JSON events. Raw string and binary messages are not supported.
Authentication
When the SDK auth is enabled, every WebSocket hook receives the current user as ctx.user, or null for an anonymous connection. Use onBeforeConnect to reject unauthenticated clients.
Connections from another origin are anonymous unless that origin is in your auth trustedOrigins. If you read your own cookies from ctx.request in onBeforeConnect, check its Origin header yourself.
ctx.user is set when the socket connects. It does not change, even if the user signs out or the session is revoked. For actions that must respect a ban or role change, re-check your records in onMessage. Then close the connection with ctx.connection.close() in rooms, or ctx.socket.close() in defineWebSocket().
Hooks
Both defineRoom() and defineWebSocket() support:
onBeforeConnect(ctx): return aResponseto reject the upgradeonConnect(ctx): runs after the socket is acceptedonMessage(ctx, event): receives the validated client eventonClose(ctx, details): receives{ code, reason, wasClean }onRequest(ctx): handles ordinary HTTP requests to the same path
The SDK completes the close handshake for you. Use onClose for app cleanup only. Do not close the socket again there.
Every hook receives a context with:
ctx.id: deterministic route instance idctx.params: matched route paramsctx.user: resolved auth user ornullctx.request: the HTTP request inonBeforeConnect,onConnect, andonRequest. InonMessageandonClose, it is a placeholder without headers, cookies, or query. To keep request data, save it inonConnectwithctx.connection.setState()in rooms orctx.socket.setState()indefineWebSocket(). Read it later fromctx.connection.stateorctx.socket.state.ctx.envctx.storage
If a route does not define onRequest(), non-WebSocket requests return 426 Upgrade Required.
Move a route while keeping its state
This step finds the route's current name before a move. Run void info. For routes/chat/[room].ws.ts, the output includes:
To preserve this resource when moving its code, add this to 'defineRoom':
name: "chat-room",This step pins that name in your existing definition.
export default defineRoom({
name: 'chat-room',
messages: { client: ClientMessage, server: ServerMessage },
// Keep your existing hooks.
});Move the file to routes/rooms/[room].ws.ts, update clients to connect to /rooms/:room, and deploy normally. Keep the same parameter names and values. Room general keeps its storage. The Worker class, binding, and migration history stay the same on both Cloudflare and SDK platform deployments.
Both defineRoom() and defineWebSocket() accept optional name. Use a static string, inline or in a local const. Each route needs a distinct Worker class. A new name selects a different resource. If you already moved a route, void info also shows unmatched identities recorded in void.lock.json. Identify the original resource before you adopt a suggested name. Otherwise, restore the original source and find the name there.
Rename a route parameter
By default, the instance key includes parameter names. /chat/:room with room: 'general' uses room=general. A route without parameters uses default. Multiple parameters are joined in route order, for example team=acme&room=general. If a multi-parameter route has a value containing &, its key uses a versioned encoding to keep rooms separate.
This step keeps the old key when you also rename [room] to [id].
// routes/rooms/[id].ws.ts
export default defineRoom({
name: 'chat-room',
key: ({ params }) => `room=${params.id}`,
messages: { client: ClientMessage, server: ServerMessage },
// Keep your existing hooks.
});Clients now connect to /rooms/:id with params: { id: 'general' }. The key remains room=general, so storage and ctx.id stay the same. Returning only params.id would select a different instance.
key is optional on both WebSocket helpers. It must return a string synchronously and consistently for the same parameters. Preserve the complete old key when migrating multiple parameters. Keep the callback stable after deployment.
Constraints
Each socket connects to one route instance. To switch rooms, close the current connection and open another. For publishing to multiple topics, see Live Event Streams.
Deployment
WebSocket routes work on Cloudflare and SDK platform deployments. Commit void.lock.json when the SDK adds a binding or migration. Do not delete or reorder deployed migration steps.