RPC over WebSocket
Pub/sub is about broadcasting interest. But sometimes a client wants the opposite: a precise answer to one specific question — “what is the history of this room?”, “is this username taken?”. Over HTTP that is just a request and its response. Over a WebSocket there is no such thing built in: you send a message and, separately, onmessage fires for every incoming message. Nothing ties a reply back to the question that triggered it. RPC over WebSocket is the small pattern that rebuilds request/response on top of a one-way message stream.
The problem: messages have no replies
Section titled “The problem: messages have no replies”A WebSocket is a stream of independent frames in each direction. When you call ws.send and later a message arrives, you have no language-level way to know that message answers this send. If you fire three requests and three replies come back — possibly out of order — they all land in the same onmessage handler as an undistinguished pile. You need to stitch each reply to its request yourself.
The fix: correlation ids
Section titled “The fix: correlation ids”The trick is to tag every request with a unique correlation id, and require the server to echo that same id on the reply. The client keeps a table of pending requests keyed by id. When a message arrives, you look up its id, find the waiting promise, and resolve it. The flow looks like this:
sequenceDiagram
participant App as App code
participant RPC as RPC client
participant WS as WebSocket
participant Srv as Server
App->>RPC: call('getUser', {id: 7})
RPC->>RPC: id = 'r1'; store pending['r1'] = resolve
RPC->>WS: send {id:'r1', method:'getUser', params:{id:7}}
WS->>Srv: {id:'r1', ...}
Srv-->>WS: {id:'r1', result:{name:'Sam'}}
WS-->>RPC: onmessage {id:'r1', result:...}
RPC->>RPC: lookup pending['r1'] -> resolve(result)
RPC-->>App: promise resolves with {name:'Sam'} Because each request carries its own id, replies can arrive in any order — the id, not the arrival sequence, decides which promise resolves. That is what makes a single connection safe to multiplex across many concurrent calls.
Three pieces you always need
Section titled “Three pieces you always need”A usable RPC client over WebSocket is essentially three things:
- A pending map.
Map<id, { resolve, reject, timer }>— one entry per in-flight call, removed when the reply lands or the call times out. - An id generator. Any source of unique strings — a counter, a
crypto.randomUUID(). It only has to be unique among currently pending calls. - A timeout. The fatal flaw of naive RPC is the reply that never comes (server crash, dropped message). Without a timeout the promise hangs forever and the pending map leaks. Every call gets a timer that rejects and cleans up if no reply arrives in time.
See it live
Section titled “See it live”The demo wires a real-WebSocket-style mock to a complete RPC client. We make two successful calls (add and echo) whose replies arrive out of order, and one call to a method the server ignores — to prove the timeout fires and rejects rather than hanging. Run it and watch the ids match replies to calls regardless of order, and the missing reply reject on schedule.
Notice the order in the log: add and echo are sent first and second, but echo resolves first because its reply was faster — and that is fine, because each promise is keyed by id, not by order. Meanwhile noReply never gets an answer, so after the timeout its promise rejects with a clear error and its pending entry is removed. That cleanup is the difference between a robust client and a slow memory leak.
Designing the wire format
Section titled “Designing the wire format”Keep the envelope minimal and symmetric. A request carries id, method, and params; a reply carries the same id and either result or error. If you want a standard instead of inventing your own, JSON-RPC 2.0 defines exactly this shape — id, method, params, result/error — and is a sensible default to copy. Either way the rule is fixed: the reply must echo the request’s id, or the client cannot route it.