Why we did not build this on WebSockets
When we started Runnev, the obvious transport was a WebSocket. We spent three weeks on it and then took it out. Here is what pushed us to plain HTTP instead, and why we think it was the right call for event delivery to the open internet.
The upgrade handshake is a liability at the edge
A WebSocket starts life as an HTTP request with Upgrade: websocket and
Connection: Upgrade. If everything between the client and the server
forwards those headers faithfully and switches protocols, you get a socket. If anything
in the path does not, you get a plain HTTP response and a confused client.
In our early testing we saw the handshake fail in three recurring places. Corporate
forward proxies that terminate and re-originate HTTP would strip the upgrade headers and
hand back a 200 with an HTML body. A couple of CDN configurations answered
the upgrade on plans where WebSocket passthrough was not enabled, so the socket
connected to the edge and then silently did nothing. And one enterprise firewall we
tested against allowed the handshake but killed the connection the moment it carried a
frame it could not inspect.
None of these are bugs we could fix. They are the environments our users' users sit behind. A transport that works on a developer's laptop and fails inside a bank is not a transport we can ship.
Serverless cannot hold a socket, but it can stream a response
A large fraction of the code that wants to consume events now runs in function runtimes: short-lived, billed by wall-clock duration, with no notion of a long-lived socket you own. You cannot pin a WebSocket open in a function that is going to be frozen between invocations.
What those runtimes can do is stream an HTTP response body. A handler can start
a fetch to a subscribe endpoint, read from the response stream, and process
batches as they arrive, all within a single invocation. When the invocation ends, the
cursor is persisted and the next one resumes. The subscribe side of Runnev is server-sent
events precisely because SSE is a response body, and a response body goes everywhere an
HTTP request goes.
Mobile radios punish idle sockets
On a mobile network, an idle socket is a battery cost the carrier wants to reclaim. Radios drop to low-power states and NAT bindings expire, so a socket that has been quiet for a few minutes may be dead without either end being told. WebSocket ping/pong exists to paper over this, and it works, but it means every client is running a timer and every server is tracking liveness for connections that are mostly silent.
With an HTTP stream, the same problem has a cheaper answer. We send a comment frame every
15 seconds, which is enough to keep intermediaries from reaping the connection, and if
the connection does die, resuming is a new GET with a cursor query
parameter. There is no session to re-establish, no re-auth beyond the bearer header that
was already on the request. A flaky link becomes a series of short reads that each pick
up where the last one stopped.
HTTP/2 gives you multiplexing you would otherwise build
The strongest argument people make for WebSockets is avoiding head-of-line blocking and per-message overhead. Over HTTP/1.1 that argument has teeth. Over HTTP/2 and HTTP/3 it mostly evaporates: many concurrent requests share one connection, headers are compressed, and flow control is built in. A client subscribed to forty streams holds forty concurrent GET responses on a single connection, and the transport sorts out the interleaving.
We measured the per-batch overhead of SSE framing against a raw WebSocket frame for our payloads and the difference was in the noise next to TLS and JSON costs. For a system whose batches are tens to hundreds of events, the framing is not where the bytes go.
What we gave up
This is not free. SSE is one-directional: the server streams to the client, and anything the client wants to send goes over a separate request. For an event stream that is exactly the shape we want, but if your problem is genuinely bidirectional and low-latency in both directions, such as a collaborative cursor or a game netcode loop, a WebSocket is the better tool and we would tell you so.
We also gave up binary frames on the wire for JSON streams; SSE is text, so raw bytes are
base64 in the data field for our raw mode when delivered over
SSE. Batch replay returns the bytes verbatim, so the overhead only applies to the live
path, and for most raw users the replay path is what matters.
The trade we made is deliberate: give up bidirectionality and a little binary efficiency, and in return the transport survives proxies, CDNs, serverless, and mobile networks with no special configuration. For delivering an ordered event stream to code we do not control the environment of, that trade has paid off every time.