Request/response correlation over WebSockets #
You want await socket.request('doc.rename', { title }) to work like fetch: resolve with the server’s answer, reject on error, and time out if nothing comes back. What you have is ws.send(), which returns nothing, and a single message event that delivers replies, pushes and errors from every in-flight operation interleaved. Early attempts match replies by message type (“the next doc.renamed must be mine”), which works until two renames are in flight, or a push from another user arrives with the same type. Request/response over a WebSocket needs explicit correlation: every request carries an id, every reply names the id it answers, and the client keeps a map of promises waiting for them.
Root cause #
HTTP pairs each response with its request structurally — on HTTP/1.1 by ordering on the connection, on HTTP/2 by stream id — so application code never thinks about matching. A WebSocket is a single bidirectional stream of independent messages. Nothing in the protocol relates a server frame to a client frame, and the server is free to reply out of order, since requests may take different amounts of time. Matching by type or by order is therefore a race: it breaks as soon as there is concurrency, and concurrency is the default in any interactive UI.
Two further failure modes come from the connection being long-lived. A request sent just before a disconnect will never be answered, and without bookkeeping its promise hangs forever — the button spinner never stops. And a reply that arrives after the caller has given up (timed out or navigated away) must be ignored rather than resolving a promise nobody is waiting for.
Resolution #
The client below wraps a WebSocket with a request method. Each call generates an id, stores { resolve, reject, timer } in a Map, sends the envelope, and returns the promise. The message handler routes frames with a re field to the matching entry and everything else to push listeners. Timeouts, AbortSignal cancellation and connection loss all reject and remove the entry, so no promise outlives the conditions it depends on.
const DEFAULT_TIMEOUT_MS = 10_000;
const MAX_IN_FLIGHT = 256; // stop runaway callers queuing unbounded work
type Pending = {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>;
type: string;
};
export class RequestTimeoutError extends Error {}
export class RemoteError extends Error {
constructor(public code: string, message: string, public retryable: boolean) { super(message); }
}
export class RpcSocket {
private pending = new Map<string, Pending>();
private seq = 0;
private pushHandlers = new Set<(msg: any) => void>();
constructor(private ws: WebSocket) {
ws.addEventListener('message', (e) => this.onMessage(JSON.parse(e.data)));
// The connection is gone: nothing in flight can be answered on it.
ws.addEventListener('close', () => this.failAll(new Error('connection closed')));
}
request<T = unknown>(type: string, data: unknown, opts: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<T> {
if (this.ws.readyState !== WebSocket.OPEN) return Promise.reject(new Error('not connected'));
if (this.pending.size >= MAX_IN_FLIGHT) return Promise.reject(new Error('too many in-flight requests'));
// Unique per connection is enough; a counter is cheaper and more readable than a UUID.
const id = `r${++this.seq}`;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new RequestTimeoutError(`${type} timed out`));
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
opts.signal?.addEventListener('abort', () => {
if (!this.pending.delete(id)) return;
clearTimeout(timer);
// Tell the server so it can stop work; the reply, if any, will be ignored.
this.ws.send(JSON.stringify({ v: 3, kind: 'req', type: 'rpc.cancel', id: `${id}c`, data: { id } }));
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer, type });
this.ws.send(JSON.stringify({ v: 3, kind: 'req', type, id, data }));
});
}
onPush(fn: (msg: any) => void) { this.pushHandlers.add(fn); return () => this.pushHandlers.delete(fn); }
private onMessage(msg: any) {
if (msg.kind === 'res') {
const p = this.pending.get(msg.re);
if (!p) return; // late reply after timeout/abort: drop it
this.pending.delete(msg.re);
clearTimeout(p.timer);
if (msg.ok) p.resolve(msg.data);
else p.reject(new RemoteError(msg.error.code, msg.error.message, msg.error.retryable));
return;
}
for (const fn of this.pushHandlers) fn(msg);
}
private failAll(err: Error) {
for (const [id, p] of this.pending) {
clearTimeout(p.timer);
p.reject(err);
this.pending.delete(id);
}
}
}
The envelope shape — kind, id, re — is the one defined in designing a WebSocket message envelope. On the server, the router must always reply to a request, including when it fails, or the client’s timeout becomes the only signal; the error half of that contract is in WebSocket error frames and error codes.
Failing everything on close is deliberate. The caller then decides whether to retry after reconnecting, and that decision depends on whether the operation is safe to repeat. Retrying a non-idempotent request blindly can apply it twice if the original did reach the server; send an idempotency key with such requests and deduplicate on the server, as in idempotent WebSocket message processing.
The three matching strategies teams usually try, in order, fail in predictable ways; only the id-based one survives every condition a real client meets.
Edge cases #
Timeouts that are too short for some operations. An export that takes 30 seconds should not share a 10-second default with a rename. Pass per-call timeouts, or have the server send an interim progress push carrying the request id so the client can extend the deadline.
Reconnect with a fresh counter. Ids only need to be unique per connection, because the pending map is failed on close. If you instead keep the map across reconnects to retry automatically, switch to globally unique ids so a reply from the old connection cannot resolve a request re-sent on the new one.
Server-initiated requests. Some protocols let the server ask the client something — “confirm this action”, “report your state”. The same mechanism works in reverse: the server keeps a pending map keyed by its own ids, and the client replies with re.
Verification #
Test concurrency and loss, not just the happy path. With a mock server you control, send three requests, reply to them in reverse order, and assert each promise resolves with its own data. Then close the socket with a request in flight and assert it rejects immediately rather than at its timeout:
it('rejects in-flight requests when the socket closes', async () => {
const { client, server } = await mockPair(); // e.g. vitest-websocket-mock
const rpc = new RpcSocket(client);
const p = rpc.request('doc.export', { id: 'd1' });
await server.nextMessage; // request reached the server
server.close(); // no reply will ever come
await expect(p).rejects.toThrow('connection closed');
});
In production, record request latency per type from the client, and count timeouts per type. Timeouts on one type point at a slow handler; timeouts on every type at once point at the connection or the node.
Operational checklist #
FAQ #
Why not use Socket.IO acknowledgements? #
Socket.IO’s emit with an ack callback implements this same pattern, including timeouts in recent versions. If you use Socket.IO, use its acks. On raw WebSockets, the class above is the equivalent.
Should request ids be UUIDs? #
Not necessarily. Ids only need to be unique among in-flight requests on one connection, so an incrementing counter works and is easier to read in logs. Use UUIDs when ids must be unique across connections, for example when they double as idempotency keys.
How do I handle a reply to a request I already timed out? #
Ignore it: the entry is gone from the map, so the lookup fails and the frame is dropped. If the operation had side effects on the server, the UI may need to refresh state, which is one reason to prefer idempotent operations and server-side state pushes.
Can I multiplex many logical channels over one socket this way? #
Yes — correlation ids handle request/response, and pushes can carry a stream field for independent ordered channels. That combination is essentially what GraphQL subscriptions over graphql-ws do.
Related #
- Designing a WebSocket Message Envelope — the fields used here.
- WebSocket Error Frames and Error Codes — what rejected promises carry.
- Building a WebSocket Message Router in TypeScript — the server side of each request.
- Optimistic UI Rollback on WebSocket Nack — correlation used for optimistic updates.
Back to WebSocket Message Protocol Design.