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.

Concurrent requests, replies out of order Two requests are sent with ids a1 and a2; the server replies to a2 first and a1 second, and the client library resolves each promise by matching the re field. Concurrent requests, replies out of order UI Client lib Server request A (slow) id=a1 doc.export request B (fast) id=a2 doc.rename re=a2 ok resolve B re=a1 ok resolve A Matching by order or by type would have resolved A with B's reply
Only an id survives out-of-order replies.

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.

Every pending request ends exactly once A pending request ends in exactly one of five terminal states: resolved, rejected, timed out, aborted or failed on connection close. Every pending request ends exactly once Pending in map, timer armed Resolved ok reply Rejected error reply Timed out timer fired Aborted caller cancelled Failed socket closed re, ok re, error deadline signal close Each exit deletes the map entry and clears the timer, so late replies find nothing to resolve
Five exits, one entry removal each — no leaked promises, no double resolution.

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.

Ways to match a reply to its request Comparison of matching replies by next message, by type, by FIFO order and by correlation id under concurrent calls, out-of-order replies and pushes that share a type. Ways to match a reply to its request Concurrent calls Out-of-order replies Pushes of same type Next message wins breaks breaks breaks Match by type breaks breaks breaks FIFO queue works breaks works Correlation id works works works FIFO only works if the server promises in-order replies, which forbids concurrency on the server
Only an explicit id is correct without constraining the server.

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.

Back to WebSocket Message Protocol Design.