Versioning WebSocket message schemas #
You ship a server change that renames user to author in chat messages. Every browser tab that loaded your app before the deploy — thousands of them, some open since last week — now renders “undefined said:” and crashes on the next message. With HTTP APIs, a breaking change mostly hits clients that call the endpoint after the deploy; with WebSockets, it hits every client that is already connected, running old code, and will stay connected for hours or days. Real-time protocols need an explicit versioning strategy because the gap between server and client versions is measured in connection lifetimes, not page loads.
Root cause #
A web deploy updates the server instantly and the client lazily. The new JavaScript bundle reaches a user only when they reload, and single-page apps with long-lived WebSockets are precisely the apps users do not reload. Native apps are worse: some users never update. So at any moment the server is talking to a spread of client versions, and every message it pushes must be understood by all of them.
Two properties of WebSocket traffic make this harder than REST. Server-initiated pushes have no request that could carry an Accept-Version header, so the server must remember each connection’s version. And there is no natural boundary for a migration: a REST client makes a fresh request each time and can be steered to /v2, while a socket opened on the old protocol stays on it until it reconnects.
Resolution #
Use three tools, in order of preference. First, make changes additive whenever possible: add fields, never rename or remove them, and have clients ignore fields they do not know. Second, when a breaking change is unavoidable, negotiate the version at connect time and store it on the connection. Third, keep the server’s internal model at the newest version and downcast outbound messages (and upcast inbound ones) for older connections at the edge, so business logic only ever deals with one shape.
import type { WebSocket } from 'ws';
import type { IncomingMessage } from 'node:http';
const SUPPORTED = [3, 4] as const; // oldest first; drop 3 when its traffic hits zero
type Version = (typeof SUPPORTED)[number];
const CURRENT: Version = 4;
const CLOSE_UNSUPPORTED = 4010;
// Internal (current) shape of a chat message.
interface ChatMessageV4 { id: string; author: { id: string; name: string }; body: string; sentAt: number }
// Per-type downcasters: current -> older. Only breaking changes need an entry.
const downcast: Record<string, Partial<Record<Version, (d: any) => unknown>>> = {
'chat.message': {
3: (m: ChatMessageV4) => ({ id: m.id, user: m.author.name, userId: m.author.id, text: m.body, ts: m.sentAt }),
},
};
// Per-type upcasters: older -> current, for inbound requests.
const upcast: Record<string, Partial<Record<Version, (d: any) => unknown>>> = {
'chat.send': {
3: (d: { text: string }) => ({ body: d.text }),
},
};
// Negotiate from the subprotocol list the client offers: e.g. ['app.v4', 'app.v3'].
export function negotiate(req: IncomingMessage): Version | null {
const offered = (req.headers['sec-websocket-protocol'] ?? '').split(',').map((s) => s.trim());
for (const v of [...SUPPORTED].reverse()) { // prefer the newest both sides speak
if (offered.includes(`app.v${v}`)) return v;
}
return null;
}
export function encodeFor(version: Version, type: string, data: unknown): string {
const fn = version === CURRENT ? undefined : downcast[type]?.[version];
return JSON.stringify({ v: version, kind: 'push', type, data: fn ? fn(data) : data });
}
export function decodeFrom(version: Version, type: string, data: unknown): unknown {
const fn = version === CURRENT ? undefined : upcast[type]?.[version];
return fn ? fn(data) : data;
}
export function onConnection(ws: WebSocket & { version?: Version }, req: IncomingMessage) {
const v = negotiate(req);
if (!v) return ws.close(CLOSE_UNSUPPORTED, 'upgrade required: reload the app');
ws.version = v;
metrics.connectionsByVersion.inc({ version: String(v) });
}
On the ws server, use the handleProtocols option to select the matching subprotocol during the handshake so the browser sees it in ws.protocol; the mechanics are covered in WebSocket subprotocol negotiation. Negotiating in the handshake has a useful property: a client whose version is no longer supported fails before any application message is exchanged, and receives a close code it can turn into “please reload” instead of rendering garbage.
Fan-out needs care. When one broadcast reaches connections on different versions, serialize once per version, not once per connection: group the room’s members by ws.version and call encodeFor once for each group. That keeps the serialize-once property from WebSocket rooms and channel subscriptions.
Making additive changes safe #
Additive evolution only works if clients are tolerant readers. In TypeScript with Zod, that means schemas use the default non-strict object parsing (unknown keys are stripped, not rejected), and the client’s message switch has a default branch that ignores unknown types instead of throwing. Enforce it in the client codebase: a lint rule or test that feeds every handler a message with an extra field and an unknown type, and asserts nothing throws.
Servers must be tolerant too, of old requests. Keep upcasters for inbound requests for as long as the old version is supported, and write a contract test per supported version that replays recorded frames from that client version against the current server — the technique described in contract testing WebSocket messages.
Verification #
Export connections by protocol version and watch the old version decay after each release. The support window is over when the old version’s share is effectively zero for a full week, not when the calendar says so:
# Share of connections on each protocol version.
sum by (version) (ws_connections_open) / ignoring(version) group_left sum(ws_connections_open)
Before removing a version, flip it to “warn” for a period: accept the connection but push a client.update_required message that the UI turns into a reload prompt. Then remove it from SUPPORTED, and clients that still offer only the old version get close code 4010 and a clear message.
Operational checklist #
FAQ #
Should the version go in the URL or the subprotocol? #
Either works. The subprotocol is the standard negotiation mechanism and lets the server pick from a list the client offers. A URL path (/ws/v4) is simpler to route at the proxy layer. Pick one and use it consistently.
How long should I support an old version? #
Until its traffic is negligible, measured, rather than for a fixed period. For browser-only products that is often days; with native apps it can be months. Prompting a reload shortens the tail considerably.
Can I force connected clients to reload? #
You can push a message asking the client to reload, and a well-built client can show a banner or reload at an idle moment. Forcing an immediate reload loses unsaved state, so prefer a prompt unless the old version is actively harmful.
Does versioning apply to server-to-server WebSockets too? #
Yes, although you usually control both sides and can deploy them together. Negotiation still helps during rolling deploys, when old and new instances coexist for minutes.
Related #
- Designing a WebSocket Message Envelope — where
vlives. - WebSocket Subprotocol Negotiation — the handshake mechanism.
- Contract Testing WebSocket Messages — proving old clients still work.
- Validating WebSocket Messages with Zod — tolerant parsing in practice.
Back to WebSocket Message Protocol Design.