Skip to content

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.

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 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'}
A correlation id ties each reply back to its request

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.

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.

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.

JavaScript

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.

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.

Why does RPC over a WebSocket need a correlation id on every request?
In the demo, why can echo resolve before add even though add was sent first?
What is the role of the timeout in the RPC client?