Realtime without WebSockets: streaming results you can't predict
In an earlier post I argued for getting the model off the request path: the user action writes a row and enqueues a job, and the answer shows up later. That raises the obvious question — later how? Something has to carry the result from a worker back to a browser that has already moved on to the next thing. The reflex is to reach for WebSockets. Most of the time that's the wrong reflex.
If the data only flows one way, don't pay for a socket that flows both.
SSE over WebSocket for one-way push
Server-Sent Events are just a long-lived HTTP response that dribbles out
text/event-stream as things happen. Reconnection is in the spec. It
passes through proxies and load balancers because it is ordinary HTTP —
there's no second protocol to secure, operate, and debug at 2am. WebSockets earn
their complexity when the client is also streaming continuously:
live cursors, collaborative editing, audio. For "your task finished, here's the
result," a socket is a standing bidirectional connection you're using in exactly
one direction. SSE is the honest fit.
The client must survive a gap
Connections die. Phones sleep, tunnels reset, a proxy quietly kills an idle
stream. If your design assumes the pipe stays up, you will eventually drop the one
event that mattered and never notice. So build reconnect in from the first commit:
every event carries a position, and on reconnect the client says "I last saw N" and
the server resumes from there. SSE hands you this for free — the browser sends the
last id back as Last-Event-ID, and your only job is to honor it.
A watermark per view, not per world
The tempting shortcut is one global "latest" cursor per user. It breaks the instant they have two views open, or switch between them. An inbox, a single thread, a feed — each advances at its own pace. Give each view its own watermark. Then a reconnect replays only what that view actually missed, instead of firehosing the client with everything that happened while it was away. It's more bookkeeping, and it's the difference between a reconnect that's instant and one that visibly stutters while it catches up on things you don't even care about.
Fan-out is a broadcast, not N polls
When one server-side event is relevant to many connected clients, push it to a channel they subscribe to; don't make each client poll to discover it. But watch the shaping. The same event often means different things to different viewers — visible to one, redacted for another, counted differently for a third. Do that shaping once, at fan-out, keyed by viewer. The naive version re-runs a per-client query on every event and tips over at precisely the moment a lot of people are watching — which is the one moment you needed it to hold.
Keep the two halves clean
The stream is how the deferred result surfaces; the request that kicked it off returned in milliseconds. Don't let them bleed together. The HTTP handler's job ends at "row written, job enqueued, here's an id." The SSE stream's job is "watch for anything about that id and forward it." Two clean halves. The day the handler starts waiting on the model "just this once," you've quietly put the slow dependency back on the request path you worked so hard to get it off.
The boring truth
Realtime for an AI product isn't a transport problem, it's a resumability problem. Once you accept that the connection will drop and the result will arrive at an unpredictable time, the design falls out on its own: a position on every event, a watermark per view, a cheap resume. Pick SSE and most of that comes in the box. The streaming part is easy. Not losing an event across a dropped connection is the actual work.