Server-sent events in Rails freeze the process they run in
Server-sent events look like the cheap half of WebSockets: one direction, plain HTTP, no extra server. Rails has shipped the controller side since 4.0 and it really is six lines. Then you measure what one open connection does to everything else and the shape of the feature changes.
Everything below ran on Rails 8.1.4 and Puma 8.0.2, in a rails new --minimal app in production
mode.
The six lines
class StreamController < ActionController::Base
include ActionController::Live
def events
response.headers["Content-Type"] = "text/event-stream"
sse = ActionController::Live::SSE.new(response.stream, event: "tick")
3.times { |i| sse.write({ n: i }) }
ensure
sse&.close
end
end
And what comes back is readable by eye, which is most of SSE's appeal:
event: tick
data: {"n":0}
event: tick
data: {"n":1}
Two newlines terminate a message. The event: line is optional and gives the browser something to
name a listener after. SSE is a thin wrapper that does the formatting and calls to_json on a
hash, and response.stream.write("data: one\n\n") does the same job by hand.
The ensure is not decoration. A controller that returns without closing the stream leaves the
connection open and the process holding it, which the next section is entirely about.
One stream, and the whole process waits
Here's the measurement that matters. A stream that runs for 4 seconds, and an unrelated request to a trivial endpoint issued while it's open:
| Plain request issued at | Time it took |
|---|---|
| t = 1s | 3.002s |
| t = 3s | 1.010s |
It waits for precisely as long as the stream has left. Not a bit of latency, not a slow response. It is queued until the stream is done.
The obvious explanation is that the stream is using up Puma's threads, and that explanation is wrong. The same server, with the same three threads, running a plain action that just sleeps for four seconds:
| Time it took | |
|---|---|
Unrelated request during a plain sleep 4 |
0.001s |
| Unrelated request during a 4 second SSE stream | 3.002s |
Same duration, same thread pool, one blocks everything and one blocks nothing. The difference is
ActionController::Live, and the unit of blocking is the process rather than the thread.
I did not isolate the mechanism, so take the number rather than an explanation from me. The
suspect is ActionDispatch::Executor, which is in the middleware stack and which has to stay
wrapped around a streaming response for the whole life of the body rather than for the life of the
action. What's measured is the effect, and the effect is consistent and easy to reproduce.
Workers are the isolation, not threads
The same test under WEB_CONCURRENCY=2, Puma reporting cluster mode and Workers: 2:
/tiny #1 -> 3.017s <- landed on the process holding the stream
/tiny #2 -> 0.001s <- the other worker
/tiny #3 -> 0.001s
/tiny #4 -> 0.001s
One request drew the short straw and the rest were served normally. So the blast radius of an SSE connection is one worker, and adding threads doesn't help while adding processes does.
Which turns the capacity question into something you can actually answer. It isn't "how many threads do I need per concurrent viewer", it's "how many of my processes am I willing to have parked". If you run two workers and one person opens a dashboard with a live feed on it, you have halved your server for as long as they leave that tab open.
So when is this the right tool
For an admin page that two people watch, it's fine, and six lines beats a WebSocket stack comfortably.
For anything a real number of users keeps open, the arithmetic above is the whole decision. Action Cable exists for exactly this: it runs its connections outside the request cycle rather than parked inside one, which is the difference that matters and the reason it's heavier to set up.
The middle option people forget is to stop holding the connection at all. A fetch every few seconds, or a Turbo Stream broadcast over Action Cable, delivers the same user experience for most dashboards without a parked process per viewer. SSE earns its place when the updates are frequent enough that polling would be sillier than holding the line, and when you know the viewer count.
One header to check before you ship
cache-control: max-age=0, private, must-revalidate
That's what Rails sent, and it isn't no-cache. For a stream you want nothing between you and the
browser attempting to buffer or store it, so if there's a proxy, a CDN or an nginx in the path,
that header is the first thing to look at. The usual additions are no-cache on the response and
turning off proxy buffering for the route.
What this page doesn't cover
No browser and no EventSource. Everything here is the server side, so the client half, including
the automatic reconnection that SSE gives you for free and the Last-Event-ID header that makes
it resumable, was never exercised.
Nothing here measures a long stream, a disconnecting client, or what happens when the browser goes
away without telling you, which is where ActionController::Live::ClientDisconnected comes in and
where a stream that never notices leaks the process it's parked in. And the mechanism behind the
blocking is named as a suspect above rather than proven, which somebody should do properly before
quoting a cause.
Comments
No comments yet. Be the first.