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:
- Is the system doing something?
- Is the content on screen complete?
- 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