Back to all writing

ENGINEERING/5 MIN READ

Handling disconnects and stale WebSocket responses

Request IDs, reconnection rules, and cleanup for streaming interfaces.

At Farcana, I built AI search and streaming chat interfaces. This note covers several failure cases in that kind of UI: out-of-order responses, interrupted streams, reconnection, and cleanup when a view closes.

Separate the connection from the request

The connection may live for minutes. A search request may be relevant for only a few seconds. They should not share a single lifecycle.

Use a request identifier to associate responses with the action that produced them. Track whether that request is still relevant. A response for an old search should not overwrite a newer one merely because it arrived later.

type SearchMessage =
  | { type: 'chunk'; requestId: string; text: string }
  | { type: 'complete'; requestId: string }
  | { type: 'error'; requestId: string; message: string };

function belongsToCurrentRequest(
  message: SearchMessage,
  activeRequestId: string | null,
) {
  return message.requestId === activeRequestId;
}

This is only the application-level shape. Incoming data still needs runtime validation before it can safely be treated as a SearchMessage.

Model states explicitly

“Loading” and “not loading” are rarely enough. An interface may be connecting, ready, streaming, reconnecting, complete, or failed.

Make the possible transitions explicit. For example, a temporary transport failure during a stream is not equivalent to a completed answer. Preserve partial content, explain what happened, and offer a deliberate recovery path.

The UI should answer three questions:

  1. Is the system doing something?
  2. Is the content on screen complete?
  3. What can I do next?

Decide which requests can be retried

Exponential backoff with jitter helps avoid reconnecting every client at the same instant. But reconnecting a socket does not automatically tell you whether an operation should be replayed.

A read-only search can often be retried. A request that creates or charges for something needs stronger guarantees, such as an idempotency key understood by the server.

Set a retry limit or an elapsed-time limit. When automatic recovery stops, tell the user. Silent infinite retries are difficult to debug and frustrating to experience.

Bound the work

A stream can produce updates faster than the browser should render them. Buffer small chunks and commit at a sensible cadence instead of triggering a full component update for every tiny message.

Also bound outstanding requests, queued messages, and retained history. A UI that remains open all day should not require ever-growing memory simply because it has been connected for a long time.

If you use a query cache alongside a socket, define who owns the canonical result. Otherwise you can end up with a cache and local component state disagreeing about the same data.

Test interruption, not just completion

The most useful scenarios include disconnecting halfway through a response, changing the search while the previous request is in flight, receiving a malformed message, and navigating away before completion.

Verify that listeners and timers are cleaned up. Verify that an old response cannot revive a disposed view. And measure more than averages: tail latency and timeout counts reveal experiences that an average can hide.

Questions or feedback?

Email me about this article

Gate of Gilvex

Survive 30 seconds. Avoid the blades and transform to dash. Near misses earn points.

Time
30.0s
Score
0
Shields
3
This arcade game needs a browser with Canvas support.

Survive 30 seconds.

Loading…

Best 0

WASD / arrows to move Space to dash

Options

Move with WASD, arrows, or the touch pad. Space or Dash transforms you briefly.

Your cyan core is the hitbox. Near misses earn points.

P pauses. Escape exits.