Real-Timewebsocketsmessage schemaacksreconnectsequencerealtime

WebSocket Message Contracts

A WebSocket gives you a pipe, not a protocol. Everything HTTP provided for free — operations, status codes, request/response pairing — you must now design: typed message envelopes, acks, errors, sequence numbers, and a reconnect story clients can actually implement.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Once the connection upgrades, what may each side send, what must each side answer, and how does a client that vanished for eight seconds get back to a correct state?
Consumers
Long-lived bidirectional clients: chat and collaboration apps, trading dashboards, multiplayer sessions, device fleets — each running on networks that will drop the connection mid-session, repeatedly, as normal operation.
The promise
Every message conforms to a versioned, typed schema; every request-like message has a correlated reply or error; ordering and delivery guarantees are explicit; and reconnection is a specified protocol that restores a correct session, not a hope.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

You just lost HTTP — design its replacement

The upgrade handshake trades away more than most teams notice. HTTP gave every interaction a method, a path, a status code, a correlation between request and response, and per-request metadata. A WebSocket gives you frames of bytes in both directions and nothing else. The transport mechanics — handshake, frames, ping/pong — belong to the networking domain (WebSockets); the *protocol on top* is 100% your contract, and "we send JSON blobs" is the everything-POST anti-pattern reborn one layer down (API Anti-Patterns Field Guide).

The minimum viable protocol is a typed envelope: every message carries a type (its operation name), a payload whose schema the type determines, and — for anything request-like — an id the reply echoes, because replies arrive interleaved with server-initiated events and nothing else pairs them. Add the error shape now, not later: errors on a socket need the same machine-readable discipline as The Error Model: Structure Over Apology, plus one extra bit HTTP never needed — whether the error is *fatal to the connection*, *fatal to the subscription*, or *scoped to the one message*. A client that cannot tell these apart tears down healthy sessions or, worse, keeps trusting dead ones.

A message envelope that replaces what the upgrade discarded
Client → Server
  { "id": "m17", "type": "subscribe",
    "payload": { "channel": "orders:acct_9" } }

Server → Client (reply — pairs by id)
  { "id": "m17", "type": "subscribed",
    "payload": { "channel": "orders:acct_9", "seq": 4021 } }

Server → Client (event — carries its own sequence)
  { "type": "order.updated", "seq": 4022,
    "payload": { "order_id": "ord_3", "status": "shipped" } }

Server → Client (error — scope is explicit)
  { "id": "m18", "type": "error",
    "payload": { "code": "unknown_channel",
                 "scope": "message" } }   # not the connection

Delivery, ordering and acks: promise only what you build

TCP orders bytes within one connection — it says nothing about messages across reconnects, across your server instances, or between what you sent and what the client processed before it crashed. So the contract must state its delivery model per message class. Fire-and-forget (ticker updates where the next tick supersedes the last) is legitimate and cheap: promise nothing, document that a missed update is healed by the next one. At-least-once (chat messages, order events) requires machinery: server-assigned sequence numbers per channel, client acks, and server-side retention long enough to replay the gap on reconnect.

Sequence numbers are the load-bearing piece because they convert "did I miss something?" from unanswerable to arithmetic: a client holding seq 4022 that receives seq 4024 knows precisely what it lacks and can request the gap. Without them, the only correct client response to any doubt is a full state refetch — which may be your intended design (see below), but should then be the *documented* design rather than what clients converge on after debugging ghost gaps.

Acks pull in the same questions queues face: how long does the server buffer unacked messages, what happens when a slow client's buffer fills (Slow Clients and Backpressure), and does redelivery duplicate? At-least-once redelivery means consumers need dedup by message id — the same bargain as Idempotency vs Deduplication, indoors.

  • Per-class delivery model — "ticker: latest-wins, no replay; chat: at-least-once, seq-numbered, 24h replay" beats one vague global promise.
  • Sequence per channel, not per connection — connection-scoped counters reset on reconnect and can't describe what was missed.
  • Acks batched or cumulative — "ack seq ≤ N" keeps chatty channels from doubling their own traffic.
  • Heartbeat with a deadline — ping/pong intervals and the miss-count that means "dead" belong in the contract; half-open connections are otherwise discovered by silence (Polling vs Long Polling vs SSE vs WebSockets for when this machinery outweighs the need).
  • Duplicates on redelivery — say they can happen and require id-based dedup, or promise exactly-once and be wrong.

Reconnect is a protocol, not an event handler

Every WebSocket client reconnects — elevators, NAT timeouts, deploys, laptop lids. The contract question is what a reconnecting client must do to be correct again, and the answer has three tiers of server cost. Stateless resume: the client re-authenticates, re-subscribes, and refetches current state via the plain HTTP API — cheapest for the server, and entirely legitimate if the contract says "the socket is a change-notification hint; state lives at the REST endpoints". Replay resume: the client presents its last seq per channel and the server replays the gap from retention — seamless for clients, costs the server per-channel buffering with an honest retention limit and a defined answer for "gap too old: refetch". Session resume: the server keeps full session state (subscriptions, position) alive for a grace period keyed by session token — smoothest, most expensive, and now sessions are server state with expiry semantics.

Whichever tier you choose, specify the client's obligations exactly: backoff with jitter on reconnect attempts (a deploy disconnecting 50k clients simultaneously creates a thundering herd that Retries and Timeouts as Contract Guidance logic must defuse), what to send first on reopen, and how to detect that resume failed and a refetch is required. And version the protocol itself — a hello {version} exchange at open — because message schemas evolve like any contract (Backward Compatibility: The Real Rules): new message types must be ignorable by old clients, new fields additive, and removed types a deprecation program, not a Tuesday.

The pipe-of-blobs protocol, reverse-engineered by every client
1// server sends whatever, whenever
2ws.send({ order: {...} }) // no type field
3ws.send("resync") // string? object? depends
4ws.send({ error: "bad channel" }) // which request? fatal?
5
6// client team's actual integration notes:
7// - if msg has .order, it's an update (we think)
8// - on any error, close + reopen + refetch all
9// - after reconnect, unknown gap → refetch all
10// - every deploy = full refetch stampede
A versioned protocol a client can implement from the docs
1→ { type: "hello", payload: { proto: "2" } }
2← { type: "hello.ok", payload: { proto: "2",
3 heartbeat_ms: 15000 } }
4→ { id: "m1", type: "subscribe",
5 payload: { channel: "orders:acct_9",
6 resume_from_seq: 4022 } }
7← { id: "m1", type: "subscribed",
8 payload: { replayed: 2, seq: 4024 } }
9 # or: { id: "m1", type: "error", payload: {
10 # code: "gap_expired", scope: "subscription",
11 # action: "refetch" } }
12
13Rules: unknown typeignore; unknown fieldignore;
14reconnectjittered backoff, then hello + resume.

The first design works in the demo and collapses at the first deploy: every ambiguity becomes divergent client guesswork and a refetch stampede. The second costs a schema document and pays with clients that recover identically, cheaply, and without a call to support.

Key points

  • A WebSocket removes HTTP's operations, status codes and correlation — the message protocol that replaces them is your contract to design.
  • Minimum envelope: type + payload + correlation id for request-like messages, and an error shape whose scope (message / subscription / connection) is explicit.
  • Declare the delivery model per message class: latest-wins needs nothing; at-least-once needs sequence numbers, acks, retention and dedup.
  • Sequence numbers per channel turn "did I miss something?" into arithmetic; without them the only safe client move is full refetch.
  • Reconnect is a specified protocol — stateless, replay or session resume — with client obligations (jittered backoff, resume handshake) written down.
  • Version the protocol at handshake and make unknown types/fields ignorable, or every schema evolution becomes a coordinated client release.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → demo: ships raw JSON blobs over the socket; the happy path in one browser tab works beautifully.
  2. 2
    Clients → protocol: three client teams reverse-engineer three slightly different message taxonomies from observed traffic.
  3. 3
    Network → clients: a deploy drops every connection; with no resume contract, all clients refetch full state simultaneously.
  4. 4
    Backend → API: the refetch stampede is 50× normal read load; the REST API browns out, taking non-realtime users with it.
  5. 5
    Team → protocol v2: adds types and seq numbers — but old blob-clients can't be distinguished from new ones on the wire, so both dialects must be served indefinitely.
What breaks
  • Clients silently miss messages across reconnects and render stale state with full confidence — the realtime feature becomes a source of wrongness.
  • Every disconnection event (deploys above all) triggers synchronized reconnect-and-refetch storms that degrade the rest of the platform.
  • Schema changes ship as breakage: an unexpected message type crashes handlers written against the guessed protocol.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Specify the envelope (type, payload, id, seq), the error scopes, and the per-class delivery model in a schema document before the first client integrates.
  • • Choose and document the resume tier — stateless refetch, seq-based replay with a stated retention, or session resume with expiry — including the failure path when resume is impossible.
  • • Version the protocol at handshake; require clients to ignore unknown types and fields from day one.
  • • Mandate jittered reconnect backoff in the contract and enforce it with connection-rate limits, because deploys disconnect everyone at once.
Observe in production
  • • Track reconnect rate and resume success rate separately — a healthy resume rate collapsing to refetch means retention is undersized for real disconnect patterns.
  • • Monitor per-channel ack lag and buffer depth; both are your early warning for slow consumers and undersized retention ([[slow-clients-and-backpressure]]).
  • • Log protocol version and unknown-type-ignored counts per client; they tell you which clients block a message-type deprecation.
Evolve without breaking
  • • Additive evolution: new message types and new payload fields are safe once ignorability is contractual; removing or repurposing a type follows the deprecation playbook ([[deprecation]]).
  • • The handshake version gates bigger shifts: servers can serve proto 2 and 3 side by side and retire 2 on telemetry, exactly like any API version ([[versioning]]).
  • • Delivery upgrades (fire-and-forget → seq + replay) can roll out per channel, advertised in the subscribe reply, without disturbing channels that stay simple.
What it costs
  • • Sequence numbers, acks and replay buffers are real server state per connection per channel — the cost scales with your most-subscribed channels, not your average.
  • • Session resume smooths the client experience at the price of making your realtime tier stateful, which complicates deploys and load balancing ([[websockets]]).
  • • A strict protocol document front-loads design work that a blob demo defers — the demo's speed is borrowed from every future client team at interest.

Misconceptions

Claim
“TCP guarantees ordering, so our messages arrive in order.”
Reality
Within one connection, yes. Across a reconnect, across server instances, or between send and the client actually processing — no. The gap between connection-level and session-level ordering is exactly where realtime bugs live, and only sequence numbers bridge it.
Claim
“WebSockets replace the REST API for realtime features.”
Reality
The strongest designs pair them: the socket carries change notifications and low-latency interactions; durable state and resync live at plain HTTP endpoints with all their caching and tooling. The socket as the *only* source of truth forces you to reinvent replay, pagination and auth refresh inside your own protocol.
Claim
“If a client misses messages, that's a client bug.”
Reality
Missing messages is the transport working as specified — connections drop. Whether missing them corrupts client state is decided by your contract: latest-wins semantics, seq-gap detection, or replay. A contract with none of the three has delegated correctness to luck.

Apply it