WebSocket error frames and error codes #
Your client shows “Something went wrong” for every failure, because the server sends errors as free-text strings — "Invalid input", "nope", "ECONNREFUSED 10.0.3.7:5432" — sometimes as a reply, sometimes as a push with no indication of which action failed, and sometimes by closing the entire connection. The client cannot decide whether to retry, whether to show the error inline next to a form field, or whether to sign the user out. Errors are part of the protocol, and a WebSocket protocol has two distinct error channels — error frames on an open connection and close codes that end it — that need deliberate, separate designs.
Root cause #
HTTP gives every response a status code with shared meaning: 4xx means the client should change something, 5xx means the server failed, 429 means slow down, 401 means authenticate. Clients, proxies and libraries all understand them. A WebSocket has close codes for ending the connection, but nothing at all for “this one message failed and the connection is fine”. Each application invents its own, and without a design, the error channel inherits the inconsistency of whoever wrote each handler.
The most expensive mistake is using the wrong channel. Closing the connection because one message was invalid disconnects every subscription, triggers a reconnect, and loses in-flight work — all to report something that only affected one request. The opposite mistake, reporting a fatal condition such as a revoked session as an ordinary error frame, leaves a connection open that should be gone.
Resolution #
Define one error object and use it everywhere an error frame appears: inside failed replies (kind: 'res', ok: false) and in error pushes that name the stream they affect. Give it a stable machine-readable code from a closed set, a human-readable message that is safe to show, a retryable flag, and optional structured details such as the invalid field or a retry delay. Keep close codes for the handful of conditions that end the connection.
// A closed set: adding a code is a protocol change, removing one is a breaking change.
export const ErrorCode = {
INVALID: 'invalid', // payload failed validation — fix input, don't retry
NOT_FOUND: 'not_found', // the entity does not exist (or you can't see it)
FORBIDDEN: 'forbidden', // authenticated, but not allowed
CONFLICT: 'conflict', // version mismatch — refetch, then retry
RATE_LIMITED: 'rate_limited', // retry after details.retryAfterMs
UNAVAILABLE: 'unavailable', // dependency down — retry with backoff
INTERNAL: 'internal', // bug — retrying rarely helps
} as const;
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
const RETRYABLE: ReadonlySet<ErrorCode> = new Set(['rate_limited', 'unavailable', 'conflict']);
export interface WireError {
code: ErrorCode;
message: string; // safe for display; never a stack trace or SQL
retryable: boolean;
details?: { field?: string; retryAfterMs?: number; currentVersion?: number };
}
// Close codes for connection-ending conditions only (4000–4999 are application-defined).
export const Close = {
SESSION_EXPIRED: 4001, // refresh credentials, reconnect
SESSION_REVOKED: 4003, // do not reconnect; sign out
UNSUPPORTED_VERSION: 4010, // reload the app
TOO_MANY_VIOLATIONS: 4029, // repeated protocol abuse; back off hard
} as const;
export class AppError extends Error {
constructor(public code: ErrorCode, message: string, public details?: WireError['details']) {
super(message);
}
}
// The only function that turns an exception into a wire error.
export function toWire(err: unknown): WireError {
if (err instanceof AppError) {
return { code: err.code, message: err.message, retryable: RETRYABLE.has(err.code), details: err.details };
}
return { code: 'internal', message: 'Internal error', retryable: false }; // hide internals
}
// Per-connection violation counter: repeated invalid frames escalate to a close.
const MAX_VIOLATIONS_PER_MINUTE = 20;
export function recordViolation(conn: { violations: number[]; ws: import('ws').WebSocket }) {
const now = Date.now();
conn.violations = conn.violations.filter((t) => now - t < 60_000);
conn.violations.push(now);
if (conn.violations.length > MAX_VIOLATIONS_PER_MINUTE) {
conn.ws.close(Close.TOO_MANY_VIOLATIONS, 'too many invalid messages');
}
}
toWire is the choke point that keeps internal details out of client messages: only AppErrors thrown deliberately by handlers carry their message across; everything else becomes a generic internal error, and the original is logged with the connection id, as in structured logging for WebSocket connections. The escalation from error frames to a close for repeated violations gives you a firm response to abusive clients without punishing the occasional bad message.
On the client, handle errors by code, never by message. invalid with details.field highlights a form field; conflict triggers a refetch and a retry; rate_limited waits retryAfterMs; forbidden removes the affected UI; internal shows a generic failure. Because retryable is computed on the server, generic retry logic in the request layer from request/response correlation over WebSockets can act on it without knowing individual codes.
Edge cases #
Errors for pushes. A failure that is not a reply — losing access to a room mid-stream, a subscription whose source was deleted — should be a push with type: 'error' and the affected stream, so the client can tear down just that part of the UI.
Not found versus forbidden. Returning forbidden for a resource the user cannot see confirms that it exists. For tenant-isolated data, return not_found in both cases, matching what per-channel authorization exposes.
Localisation. Clients should map codes to their own localized strings and use message only as a fallback. The server’s message is for developers first.
Verification #
List every throw in your handlers and check that each one is either an AppError with a deliberate code or a genuine bug that should surface as internal. Then graph error replies by code and type; each code should have a stable baseline, and a new spike maps directly to a cause: invalid to a client release, unavailable to a dependency, internal to a server bug.
# Error replies by code in the last hour, from structured logs.
jq -r 'select(.event=="ws.error_reply") | "\(.code)\t\(.type)"' app.log | sort | uniq -c | sort -rn | head
Finally, run a fuzz test: send each handler random payloads and assert that the reply is always a well-formed error frame and the connection stays open.
Operational checklist #
FAQ #
Can I reuse HTTP status codes in WebSocket error frames? #
You can use numbers like 400 and 404 if they help your team, but they carry HTTP connotations that do not always fit — there is no equivalent of a redirect or a cache. Short string codes are clearer on the wire and in logs. Whatever you choose, make the set closed and documented.
What’s the difference between 1008 and a 4xxx close code? #
1008 Policy Violation is a registered generic code meaning the endpoint received a message that violates its policy. Codes in 4000–4999 are reserved for applications, so you can give each connection-ending condition its own meaning. Use 4xxx when the client should react differently to different causes.
Should validation errors include which field failed? #
Yes, in details.field, so the client can place the error next to the input. Include only the field path, not the submitted value, to avoid echoing data into logs.
How do I report errors for fire-and-forget messages? #
If a message has no request id, there is nothing to correlate a reply with. Either give every client message an id, so errors always have a home, or push an error that names the message type and let the client surface it generally.
Related #
- Request/Response Correlation over WebSockets — where error replies land.
- WebSocket Close Codes Explained — the registered close codes.
- Building a WebSocket Message Router in TypeScript — the choke point that produces them.
- Avoiding Reconnect Loops on Auth Failure — client handling of 4xxx closes.
Back to WebSocket Message Protocol Design.