Contract testing WebSocket messages #

The frontend test suite is green, the backend test suite is green, and production breaks: the server now sends createdAt as an ISO string, the client still parses it as a number, and every timestamp renders as “Invalid Date”. Each side was tested against its own idea of the protocol — the frontend against mocks that send numbers, the backend against assertions that expect strings — and nothing tested that the two ideas match. REST APIs have OpenAPI and a mature contract-testing ecosystem. WebSocket protocols usually have nothing, which is why message-shape regressions are among the most common real-time outages. This page builds three layers of contract checks that fit a WebSocket protocol.

Root cause #

A WebSocket protocol is an implicit contract spread across two codebases: the shapes of messages each side sends, the order in which they arrive, and the meaning of each type. Unit tests on either side encode one side’s assumptions. Mocks encode them too — a mock server written by the frontend team sends what the frontend team thinks the server sends. When the real server changes, the mock does not, and the tests keep passing.

WebSocket protocols add two hazards REST does not have. Push messages are sent without a request, so there is no endpoint to point a contract test at; the contract is “whenever X happens, the server sends a message shaped like Y”. And long-lived clients mean the server must remain compatible with old client versions for hours or days after a deploy, as discussed in versioning WebSocket message schemas, so the contract has more than one consumer version at a time.

Three layers of WebSocket contract checks Layers of WebSocket contract testing: shared schemas used everywhere, replay of recorded sessions, consumer-declared expectations verified by the server, and sampled validation of production traffic. Three layers of WebSocket contract checks Shared schemas one package of Zod schemas imported by server, client and mocks compile + runtime Recorded-session replay real sessions per client version replayed against the new server CI, server side Consumer expectations each client version declares messages it relies on; server verifies CI, both sides Production validation sampled runtime checks on real traffic, alert on drift runtime Each layer catches what the one above it cannot
Shared types stop most drift; replay and expectations stop the rest.

Resolution #

Layer 1: one schema package. Define every message in a shared package of Zod schemas, and make the server validate outbound messages (in tests and in development), the client parse inbound messages, and the mocks construct messages through the same schemas. A change to a message shape then breaks compilation or a test on whichever side has not been updated.

// packages/protocol/src/messages.ts — imported by server, web client and test mocks
import { z } from 'zod';

export const ChatMessage = z.object({
type: z.literal('chat.message'),
seq: z.number().int().nonnegative(),
id: z.string(),
author: z.object({ id: z.string(), name: z.string() }),
text: z.string().max(4000),
createdAt: z.string().datetime(), // ISO 8601 — the contract, in one place
});

export const Snapshot = z.object({
type: z.literal('snapshot'),
seq: z.number().int().nonnegative(),
messages: z.array(ChatMessage),
});

export const ServerMessage = z.discriminatedUnion('type', [ChatMessage, Snapshot]);
export type ServerMessage = z.infer<typeof ServerMessage>;

// Test helper: mocks must build messages through the schema, so they cannot drift.
export function fixture<T extends z.ZodTypeAny>(schema: T, value: z.input<T>): z.infer<T> {
return schema.parse(value);
}

Layer 2: replay recorded sessions. Shared schemas cannot help when an old client version, compiled against an old schema, is still connected. For those, record real sessions per client version — the frames the client sent, in order — and replay them against the new server in CI, asserting that every server reply and push still parses under that version’s schema.

// server/test/replay.test.ts
import { readdirSync, readFileSync } from 'node:fs';
import WebSocket from 'ws';
import { startTestServer } from './harness';

const SESSIONS_DIR = 'test/sessions'; // e.g. v41-chat.jsonl, v42-chat.jsonl

for (const file of readdirSync(SESSIONS_DIR)) {
it(`replays ${file} without contract violations`, async () => {
const { url, stop } = await startTestServer();
const version = file.split('-')[0]; // v41
const schemas = await import(`@acme/protocol-${version}`); // pinned old package
const frames = readFileSync(`${SESSIONS_DIR}/${file}`, 'utf8').trim().split('\n');
const ws = new WebSocket(url, [`app.${version}`]);
const received: unknown[] = [];
ws.on('message', (d) => received.push(JSON.parse(String(d))));
await new Promise((r) => ws.once('open', r));
for (const f of frames) ws.send(f);
await new Promise((r) => setTimeout(r, 500)); // let replies arrive
for (const msg of received) expect(() => schemas.ServerMessage.parse(msg)).not.toThrow();
ws.close(); await stop();
});
}

Recording is cheap: log inbound frames for a small sample of sessions in a staging environment, strip personal data, and commit a handful per client version. Publish the protocol package with a version per release so old schemas stay importable.

Layer 3: consumer expectations. For push messages, have each client declare what it relies on — “after room.join, I expect a snapshot with messages[].author.name” — as a small JSON or TypeScript file generated from the client’s parsers. The server’s CI loads every supported client’s expectation file and verifies it with a scripted scenario. This is the consumer-driven contract idea from Pact, applied to message sequences; Pact itself supports asynchronous message contracts if you prefer an off-the-shelf tool.

Replay catches a breaking change CI replays a recorded version 41 session against the new server, which sends createdAt as a string; parsing with the version 41 schema fails because that version expected a number, and the deploy is blocked. Replay catches a breaking change CI New server v41 schema replay v41-chat.jsonl chat.message createdAt: string parse with v41 schema fail: expected number block deploy The shared schema alone would have passed: the new server and new client agree
Old clients are consumers too — replay is how you test against them.

Edge cases #

Optional versus required. Adding a required field to a message the client sends breaks old clients; adding a required field to a message the server sends is safe for old clients only if they ignore unknown fields. Contract tests should run old-client parsers in non-strict mode, as the real clients do, or they report false failures.

Ordering contracts. Some expectations are about sequence — “a snapshot always precedes the first chat.message after joining”. Encode these as scenario assertions in layer 3, not as schema rules.

Mocks built by hand. Any mock that constructs messages as object literals bypasses the schema. Enforce the fixture helper with a lint rule in test directories, so MSW handlers and other fakes cannot drift.

Verification #

Prove each layer catches what it should by introducing a deliberate break in a branch: change createdAt to a number on the server. Layer 1 should fail the server’s outbound-validation test; with the shared package updated too, layer 2 should fail on replays from older client versions. Then add a new optional field and confirm nothing fails — contract tests that block harmless additive changes train teams to ignore them.

In production, validate a small sample of outbound messages against the current schema and count failures by type:

const SAMPLE_RATE = 0.001;
export function sendChecked(ws: import('ws').WebSocket, msg: ServerMessage) {
if (Math.random() < SAMPLE_RATE) {
const r = ServerMessage.safeParse(msg);
if (!r.success) metrics.contractViolations.inc({ type: (msg as any).type });
}
ws.send(JSON.stringify(msg));
}
What each layer catches Which contract layer catches drift between current client and server, breakage of old clients, and push ordering violations. What each layer catches Same-version drift Old client broken Push ordering Shared schemas yes no no Session replay yes yes partly Consumer expectations yes yes yes Production sampling after deploy no no Start with shared schemas; add replay as soon as clients stay connected across deploys
No single layer is enough; together they cover the protocol.

Operational checklist #

FAQ #

Can I use Pact for WebSocket contracts? #

Pact supports asynchronous message contracts, which fit individual WebSocket messages: the consumer declares the message it expects and the provider proves it can produce it. It does not model connection lifecycles or ordering directly, so combine it with scenario tests for sequences.

Is AsyncAPI useful here? #

Yes, as documentation and a source for generated schemas. AsyncAPI describes channels and message payloads for event-driven APIs, including WebSockets. Generating the shared Zod or JSON Schema definitions from it keeps documentation and tests aligned.

How many recorded sessions do I need? #

A few per client version, covering the main flows (join, send, receive, reconnect) is usually enough. The goal is to exercise each message type the version uses, not to reproduce traffic volume.

Who owns the contract? #

Treat the protocol package like a public API: changes are reviewed by both frontend and backend owners, and removals follow the deprecation process in versioning WebSocket message schemas.

Back to Testing Real-Time Frontends.