implement
WebSockets

WebSockets

A duplex channel on a route — schemas both ways, a generated client that reconnects, and a disconnect you can see.

Some work needs both directions of one connection: live collaboration, presence, log tailing, an agent transport, a relay. Server-sent events give you a stream downstream and nothing upstream, and pairing one with a POST upstream runs into the browser's six-connection-per-origin limit long before it runs into anything about your app.

A server.ts can accept a WebSocket upgrade instead. Export a socket() handler as SOCKET, beside the method handlers the same file already exports:

// src/routes/api/room/[id]/server.ts
import { socket } from "./$types";

export const SOCKET = socket({
	open: (peer) => join(peer.params.id, peer),
	message: (peer, message) => broadcast(peer.params.id, message.text()),
	close: (peer) => leave(peer.params.id, peer),
});
const ws = new WebSocket(`ws://localhost:5173/api/room/${id}`);
ws.addEventListener("message", (event) => render(event.data));
ws.send("hello");

An upgrade goes through the same pipeline a request does: hooks.server.ts runs, cookies are read and written, event.locals is filled in — and only then is the connection accepted. A directory that serves a socket may still export GET and the rest; an upgrade request is routed to SOCKET, and an ordinary request to the same path is routed to the method handler as usual.

The peer

Every connection is a peer, and it is what a handler holds on to.

ida per-connection id, unique in this process — the key to hang state off
paramsthe route's params, typed by the generated ./$types
urlthe URL the upgrade was requested at, query string included
requestthe upgrade request, for its headers
localswhatever hooks.server.ts put on the event that accepted this upgrade
signalaborts when the connection is gone
readyStatethe four states WebSocket itself defines
bufferedAmountbytes sent but not yet written out
send(data)queues a message; answers with bufferedAmount after queueing
sendRaw(data)queues a frame as-is, whatever the route's outgoing schema says
close(code?, reason?)starts the close handshake
drained(limit?)resolves once bufferedAmount is at or under limit

Two connections from the same browser are two peers. That is the point — id is what a room, a presence list, or a job table is keyed by.

Without a schema, send takes a string or bytes and the distinction survives the wire: a string arrives as a text frame and a Uint8Array or ArrayBuffer as a binary one. A message read back keeps the same distinction — message.data is a string or a Uint8Array, message.binary says which, and text(), json<T>(), uint8Array(), and arrayBuffer() convert on demand, so a relay that only forwards bytes never pays to decode them. Declare the schemas below and send takes the value instead, and message.data is the parsed one; message.raw is always the frame itself either way.

Sending on a peer that has already gone is a no-op rather than an error. The client hanging up is not the sender's bug, and there is no useful way to have handled it.

The single-function form

When all a route does is write, the callbacks are more ceremony than the job needs. socket() also takes a bare function, which is the open callback, handed the peer and the signal that aborts when it disconnects:

export const SOCKET = socket(async (peer, signal) => {
	for await (const tick of ticks(signal)) peer.send(JSON.stringify(tick));
});

That signal is the one thing worth taking care over, exactly as it is for sse: a loop parked on a promise that never settles outlives the client it was writing to. Wait under the signal and the disconnect is what wakes you.

Refusing an upgrade

A socket that anyone can open is a socket anyone can open. The route's upgrade callback runs with the app's hooks already applied — so event.locals is filled in — and this is where a socket route authenticates:

export const SOCKET = socket({
	upgrade: ({ locals, params }) => {
		if (locals.user === undefined) error(401, "sign in first");
		if (!canRead(locals.user, params.id)) error(403, "not your room");
	},
	open: (peer) => join(peer.params.id, peer),
});

error(…) refuses the handshake with that status; returning a Response does the same with a response of your own. Either way the connection is never established, and the client sees a handshake that failed with the status on it. hooks.server.ts can refuse one too, by answering with its own response instead of calling resolve — the same shape as refusing any other request.

A params schema works the same way it does for handler(), and a rejection is a 400:

export const SOCKET = socket({
	params: z.object({ id: z.coerce.number() }),
	open: (peer) => peer.send(`room ${peer.params.id}`), // a number
});

Cookies a hook set on the way in go out with the handshake. So does anything a route added through event.setHeaders, which is how a route selects a Sec-WebSocket-Protocol.

Typing the messages

A route can declare what each end may send, with the same Standard Schema contract handler() uses for body and response:

// src/routes/api/room/[id]/server.ts
import * as v from "valibot";
import { socket } from "./$types";

const ClientMessage = v.variant("type", [
	v.object({ type: v.literal("join"), user: v.string() }),
	v.object({ type: v.literal("chat"), text: v.string() }),
]);

const ServerMessage = v.variant("type", [
	v.object({ type: v.literal("joined"), users: v.array(v.string()) }),
	v.object({ type: v.literal("said"), from: v.string(), text: v.string() }),
]);

export const SOCKET = socket({
	incoming: ClientMessage,
	outgoing: ServerMessage,
	message: (peer, message) => {
		//                       ^? ClientMessage
		if (message.data.type === "chat") broadcast(peer.params.id, message.data.text);
	},
});

incoming is validated on the server, because it is the untrusted half: message.data is the schema's output, narrowed. outgoing types peer.send and serializes what it is given as JSON.

outgoing is type-only at runtime, and deliberately so — unlike handler()'s response, which is checked in dev. send is synchronous because it answers with bufferedAmount for backpressure, and a Standard Schema may validate asynchronously; making every call site await to re-check what the types already state is a bad trade. peer.sendRaw goes past the schema either way, which is how the binary half of a mostly-text protocol gets sent.

There is no envelope. SSE has an event field because the format defines one; WebSocket does not, so kit inventing a { event, data } wrapper would mean kit owning a sub-protocol you would then have to speak from every other client. A tagged union over plain JSON gets the same ergonomics and stays something wscat can read.

Dispatching by kind

A switch over the discriminant works, and gets old. on does it for you, one handler per member:

export const SOCKET = socket({
	incoming: ClientMessage,
	outgoing: ServerMessage,
	on: {
		join: (peer, data) => join(peer.params.id, data.user),
		//                        ^? { type: "join"; user: string }
		chat: (peer, data) => broadcast(peer.params.id, data.text),
	},
});

Every member is required. Adding a message kind to ClientMessage is a build error until it is handled — which is the whole reason to prefer this over a switch that quietly falls through. The key defaults to type; discriminant: "kind" picks another.

message still runs for every frame when both are declared, before on dispatches the one that matched — so logging or counting every message does not mean giving up the dispatch.

A message that does not fit

A frame the incoming schema rejects — or one that is not JSON at all — is the peer talking a protocol this route does not speak. The route's error handler hears about it, and then the connection closes with 1008:

export const SOCKET = socket({
	incoming: ClientMessage,
	error: (peer, cause) => log.warn(`${peer.id} sent something odd`, cause),
	on: { join: onJoin, chat: onChat },
});

Continuing would mean acting on assumptions the wire has just contradicted, so this mirrors what a bad body does to a request: that request ends with a 400, and this connection ends.

The typed client

The generated client knows about socket routes the same way it knows about endpoints — off the module's type, with nothing evaluated:

import { api } from "$implement/client";

const room = api.SOCKET("/api/room/[id]", { params: { id } });

room.send({ type: "chat", text }); // ← the route's `incoming` schema
for await (const message of room) {
	// ← its `outgoing` schema
	if (message.type === "said") append(message.from, message.text);
}

SOCKET sits beside the seven HTTP methods because that is the name the route exports it under, and api.SOCKET(" offers only the routes that actually serve one. Unlike the others it answers with a connection rather than a promise of a result — there is no single result to wait for. The nested style has it too, at the route's own leaf: api.api.room["[id]"].SOCKET({ params }).

Async iteration is the primary way to read, the same way the client reads an sse response back; break closes the connection. onMessage(listener) is the callback form for code that cannot await — use one or the other on a given connection, since a message goes to whichever asked for it first.

statusReadable<"connecting" | "open" | "closed">
openedresolves on the first open, rejects when the first attempt is refused
send(message)typed by incoming; answers with bufferedAmount
sendRaw(data)a frame as-is
onMessage(fn)every message; returns the function that stops it
onClose(fn)every close, retried or final
close(code?, reason?)closes for good — no retry follows
bufferedAmountwhat the browser has queued

status is a readable, so a connection indicator is a binding rather than a listener and a piece of state:

Span(
	{ "data-status": room.status },
	room.status.bind((s) => (s === "open" ? "live" : "reconnecting…")),
);

Reconnecting

A browser's WebSocket never reconnects, unlike EventSource. The client does, with an exponential backoff:

const room = api.SOCKET("/api/room/[id]", {
	params: { id },
	reconnect: { retries: Infinity, delay: 500, maxDelay: 10_000 },
	onReconnect: (room) => room.send({ type: "join", user }),
});

Reconnecting is not transparent, and the client does not pretend otherwise. A message sent while the socket was down is dropped rather than queued: silently re-sending a join after a gap is a correctness bug, not a convenience. Whatever the old connection had established server-side went with it — the peer is gone, its close ran, and the new connection arrives as a new peer with a new id. onReconnect is where the app puts that back, and status is how the UI says so meanwhile.

reconnect: false turns it off, and the default is ten tries.

What a refused upgrade looks like here

opened rejects when the first attempt never connects, which is where a 401 from the route's upgrade hook surfaces — as far as a browser lets it. This is the one genuinely awkward corner of the platform: a browser's WebSocket reports a failed handshake as a contentless error event, so the status and body the server sent are not readable from script. A machine client — kit's own, curl, a service — sees them fine.

When the client has to know why, accept the upgrade and close instead:

export const SOCKET = socket({
	open: (peer) => {
		if (peer.locals.user === null) peer.close(4401, "sign in first");
	},
});

A close code in the 4000–4999 range is yours, and it reaches onClose with its reason intact.

Lifecycle

Messages are sequenced. Each callback is queued behind the one before it, so an async message handler holds the next message rather than racing it. A frame that sets up state cannot be overtaken by the frame that uses it — which is what makes a protocol of your own tractable over the channel.

A disconnect is observable, and it is where per-connection state is released. close runs with the code and reason the client sent, and clean says whether the close handshake actually completed or the socket simply died:

export const SOCKET = socket({
	open: (peer) => sessions.set(peer.id, newSession(peer)),
	close: (peer, { code, reason, clean }) => {
		sessions.delete(peer.id);
		if (!clean) log.warn(`peer ${peer.id} vanished (${code})`);
	},
});

A socket that died without a close frame reports 1006 and clean: false — the code the protocol reserves for exactly that, and one no peer can send. peer.signal aborts immediately after close runs, so a handler still sees the peer whose state it is releasing, and anything waiting on the signal wakes once that is done.

A server restart ends every connection. There is no session to resume and nothing kit keeps across one: sockets live in the process, and a deploy, a crash, or an app pool recycle takes them all with it. Clients reconnect — a browser's WebSocket does not do it for you, unlike EventSource, so a reconnect loop is yours to write — and they arrive as new peers with new ids. Per-connection state is therefore exactly that: anything that has to outlive a connection belongs somewhere a restart does not reach.

Idle connections are held open, and dead ones are dropped. Kit pings every 30 seconds, and a peer that has not answered by the time the next ping is due is dropped — so a connection whose other end vanished is noticed within a minute rather than held open indefinitely with whatever state the app built for it. A proxy in front may have an idle timeout of its own that is shorter.

Backpressure

A duplex API that gives no flow-control signal just relocates the problem, so peer.send answers with what is still queued and peer.drained waits for it to clear:

for await (const chunk of source) {
	// over a megabyte queued: stop reading the source until the socket catches up
	if (peer.send(chunk) > 1_000_000) await peer.drained(1_000_000);
}

drained(limit) resolves as soon as bufferedAmount is at or under limit, and resolves immediately if it already is — or if the peer has closed, so a producer awaiting it is never stranded on a connection that is gone.

The signal is only as good as the host underneath it. A Node server reports the socket's real write buffer. Cloudflare's WebSocket has none to report, so bufferedAmount is always 0 there and drained always resolves at once — flow control on Workers has to be a credit scheme over the channel itself (the consumer sends "send me N more") rather than a read of the socket.

Where a socket can live

HostSockets
dev, and adapter-nodeyes — the generated server attaches an upgrade listener
adapter-iisyes — web.config hands the handshake to Node
adapter-cloudflareyes — through the runtime's own WebSocketPair
adapter-vercelno — the build fails rather than deploying a route that 404s
adapter-staticno — the build fails, for the same reason

The two that cannot serve one say so at build time, naming the routes:

@implementjs/adapter-vercel: this app declares WebSocket routes, and a
serverless function cannot hold a socket open:
  api/room/[id]/server.ts

That is deliberate. A serverless function answers one request and is frozen; there is no upgrade path into it, so a socket route deployed there would not fail at deploy time — it would 404 in production, which is a worse way to find out.

Node

Nothing to configure: the server @implementjs/adapter-node writes attaches the upgrade listener itself. To mount the app inside a server you already have, attachSockets is the seam:

import { createServer } from "node:http";
import { attachSockets, handler } from "./dist/handler.js";

const server = createServer(handler);
attachSockets(server);
server.listen(3000);

A path no socket route claims is left alone, so a server with an upgrade path of its own keeps it. Kit speaks version 13 and negotiates no extensions, so a client offering permessage-deflate falls back to uncompressed frames — which is what the protocol says to do.

IIS

IIS ships its own WebSocket module, and while it is on it answers the handshake itself: the upgrade never reaches Node, and the route is never asked. The adapter writes <webSocket enabled="false" /> into web.config when the app has socket routes, which is what makes the handler in front proxy the connection through instead. The WebSocket Protocol Windows feature still has to be installed on the server — that setting says IIS is not the one speaking the protocol, not that the protocol is unavailable.

Cloudflare

A worker keeps one half of a WebSocketPair and returns the other in the 101. Waiting costs no CPU time, so an idle connection is close to free — but it lives only as long as the worker instance does, and nothing carries it across a deploy. For a connection that has to outlive one, or for many peers that have to see each other's messages, reach for a Durable Object and use the route to route into it.

In dev

Socket routes work in vite dev exactly as they do in production, on the same port — the same hooks, the same upgrade callback, the same refusals. Vite's own HMR channel is on that server too, and kit leaves it alone.

An upgrade to a path no socket route claims is dropped rather than left hanging, so a typo'd path fails the handshake here the way it would in production instead of waiting forever for an answer that is not coming.

Editing a server.ts does not migrate the connections it is holding — the module reloads, and the peers on the old one stay on the old one until they reconnect. Reload the client after changing a socket route.