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.

Error frame or close code? A decision table: failures scoped to one request or stream get error frames, temporary connection-level problems get error replies with retry hints, and failures that invalidate the whole connection get close codes. Error frame or close code? Scope Channel Example One request failed that request error reply validation, not found Push stream problem one stream error push lost access to room Client too fast connection, temporary error reply + retry_after rate limited Session invalid whole connection close 4001 / 4003 token expired Protocol broken whole connection close 1002 / 1008 garbage frames Close only when the connection itself can no longer be used
Match the channel to the scope of the failure.

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.

Error reply, then escalation An invalid rename gets an error reply naming the title field and the connection continues; the next valid request succeeds; after twenty-one invalid frames in a minute the server closes with 4029. Error reply, then escalation Client Router Handler doc.rename id=9 title="" schema fails res re=9 invalid field=title doc.rename id=10 ok title handler runs 21st invalid frame in 60 s close 4029 Individual errors are replies; only a pattern of abuse becomes a close
Errors stay local until they stop being accidents.

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.

Client handling by code Four client reactions keyed on error code: show a field error for invalid, refetch and retry on conflict, wait and retry on rate limiting, and show a generic failure for internal errors. Client handling by code invalid show field error conflict refetch, then retry rate_limited wait retryAfterMs internal generic failure Clients switch on code; message is for humans and logs
One code, one reaction — no string matching.

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.

Back to WebSocket Message Protocol Design.