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.

Client versions still connected after a deploy After a server deploy, most browser tabs reload within a day, long-lived tabs remain for several days and native app stragglers for a week or more, so the server speaks both versions for about two weeks. Client versions still connected after a deploy server must speak v3 and v4 server v4 deployed (0 d) most tabs reloaded (1 d) long-lived tabs remain (3 d) native app stragglers (7 d) v3 traffic near zero (14 d) Measure this curve for your product before choosing a deprecation window
The support window is set by your slowest clients, not your release cadence.

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.

Is this change breaking? Whether adding optional fields, adding message types, renaming fields, changing field types and removing fields break old or new clients and need a version bump. Is this change breaking? Old client reads new New client reads old Needs version bump Add optional field ignores it must default it no Add message type ignores unknown type never receives it no Rename a field field missing field missing yes Change a field's type parse error parse error yes Remove a field field missing fine yes Additive changes are free only if clients ignore unknown fields and types
Most changes can be made additive; the rest need a negotiated version.

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.

One broadcast, two protocol versions A publisher sends a v4 chat message; the node groups room members by version, serializes once for v4 clients and downcasts once for v3 clients. One broadcast, two protocol versions Publisher Node v4 clients v3 clients chat.message (v4 shape) group members by version serialize once as v4 downcast once, send as v3 renders user, text as before Business logic produced one v4 message; the edge adapted it
Versions are an edge concern — the core only knows the current shape.

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.

Back to WebSocket Message Protocol Design.