Skip to main content
WebSocketlesson 2 of 4 · 2 min read

Full Duplex Messaging

Frames, not requests

Once upgraded, your connection carries frames instead of requests.

A frame header costs 2 to 14 bytes against hundreds for even a lean HTTP request, so the overhead per message nearly disappears. Frames come in a few kinds: text, binary, ping, pong and close. Either side sends whenever it likes, with no pairing of request to response, no status codes and no methods.

Notice what you just inherited. Ordering within the connection is still guaranteed underneath, and everything above the frame layer is now your problem.

Blank slate as WebSocket's real cost. You are designing a protocol whether you admit it or not.

Put a type on every message, version it for the day the shape changes, and add correlation identifiers if you want request-style calls. When your client sends two requests and your server sends two answers, nothing but your own identifier says which belongs to which. Teams that skip this rediscover it as a bug where answers land on the wrong handler.

Dead connections and backpressure

Detect dead connections second, because your operating system will happily report one as open for minutes after the other end vanished behind a dropped wifi link.

Send a ping every 20 to 30 seconds, expect the pong back, and close anything that misses two in a row. That traffic doubles as the signal keeping idle-killing middleboxes from reaping your connection.

Handle backpressure third. A server pushing 50 messages a second at a phone on a weak signal queues frames in memory until something breaks.

Watch the send buffer and decide your policy for slow consumers in advance. Drop stale updates when the data is telemetry, or disconnect clients that fall too far behind, which is how trading platforms treat a market data feed.

the shape of it
AnnaChat serverBen1. send: hi Ben2. push3. typing event
step 1 of 3
Both directions flow at once over the same sockets, with no request waiting for a response anywhere.
everything HTTP was doing for you is now yours
Java
// There is no status code, no method, no request paired to a
// response. You are designing a protocol whether you admit it or not.
record Frame(String type, String id, Object payload) {}

ws.onMessage(raw -> {
  Frame f = decode(raw);
  switch (f.type()) {
    case "edit"     -> apply(f.payload());
    case "response" -> pending.remove(f.id()).complete(f.payload());
    default         -> log.warn("unknown frame {}", f.type());
  }
});

// Dead connections look alive for minutes. Ping, expect a pong,
// and close after two misses. The traffic also stops middleboxes
// from reaping an idle socket.
scheduler.every(Duration.ofSeconds(25), () -> {
  if (missedPongs++ >= 2) ws.close(1001, "no pong");
  else ws.ping();
});

Worked example

Jake builds an internal admin tool where the browser makes RPC-ish calls over one WebSocket: send a getUser command, wait for the reply. In testing it works; in production, a support agent with a slow query and a fast one in flight sees user A's data render in user B's panel, because Jake's code assumed replies arrive in request order. The fix is a requestId on every outbound message, echoed in the reply and used to resolve the matching promise, about 30 lines. While in there he adds ping/pong every 25 seconds and discovers the server had been holding 1,900 connected sockets for long-departed clients, phantom sessions that vanish within a minute once missed pongs start closing them. Memory per pod drops by 300 MB.