LaunchKit
← All posts
· 6 min read · by The LaunchKit team · 1 views

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.

Two four second requests compared against the same Puma process running three threads. During a plain action that sleeps for four seconds, an unrelated request returns in one millisecond, because the sleeping thread releases the interpreter and the other two threads are free. During a server-sent events stream of the same duration, the unrelated request is queued for the entire remaining life of the stream: issued one second in it takes three seconds, issued three seconds in it takes one second. A third panel shows the same test under two Puma workers, where the first request lands on the blocked process and takes three seconds while the next three are served by the other worker in about a millisecond each.

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.

#rails #http #performance

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.