Real-Time Event Streaming in Rails using ActionController::Live and Server-Sent Events (SSE)
“Real-time dashboard” is the feature that silently becomes an architecture decision. The naive version polls a JSON endpoint every second: one client is nothing, but 500 dashboards at 1 req/s is 500 req/s of pure waste, plus a database hit per poll, plus a p99 that’s really a p95 plus a scheduling jitter. The other extreme is WebSockets — a full bidirectional protocol, a persistent connection class, reconnect and heartbeat logic — for data that only ever flows one way: server to client. When the client never sends anything but “start watching,” Server-Sent Events is the right tool: a plain HTTP connection that streams data: frames, with auto-reconnect and Last-Event-ID resume built into the browser. Rails supports it natively, which means the whole thing fits in one controller action.
The shape of the handler
ActionController::Live swaps the normal “render a response” contract for a persistent response.stream. The action blocks until the client disconnects, and everything you write to the stream is pushed immediately. The SSE helper wraps the wire format (event:/data:/id: lines) and handles closing. The production shape we run — a Redis pub/sub subscription fed from wherever the events originate (jobs, model callbacks, external metrics pipelines), a heartbeat to keep proxies honest, and bulletproof teardown:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MetricsController < ApplicationController
include ActionController::Live
RETRY_MS = 5_000
def stream
response.headers['Content-Type'] = 'text/event-stream'
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
sse = SSE.new(response.stream, retry: RETRY_MS)
channel = "metrics:#{params[:id]}"
subscriber = Thread.new do
redis = Redis.new
redis.subscribe(channel) do |on|
on.message { |_, payload| sse.write(payload, event: 'metric') }
end
end
# main thread keeps the connection alive with a heartbeat frame
loop do
sse.write({ ts: Time.now.to_i }, event: 'ping')
sleep 15
end
ensure
subscriber&.kill
subscriber&.join
sse.close
rescue ActionController::Live::ClientDisconnected, IOError, Errno::EPIPE
nil
end
end
Three details in this action have cost us production incidents when omitted. The ensure block kills and joins the subscriber thread and closes the stream — a leaked subscriber keeps a Redis connection and a Ruby thread alive after the client is gone, and the stream leak compounds it, until a busy day turns into “too many open files.” The retry: directive (5s here) tells the browser how long to wait before reconnecting, so a deploy that restarts workers doesn’t leave clients hanging on a dead connection for a minute. And the X-Accel-Buffering: no header is what makes nginx pass bytes through immediately instead of buffering them — without it, nginx accumulates a chunk of your event stream and your “real-time” dashboard updates in 30-second bursts. Cloudflare/CDN proxies have equivalent buffering flags; audit them all.
The thread-per-connection math
Here is the real constraint, and it’s a Rails-specific one: ActionController::Live holds a thread per open connection. A Puma worker with threads 0, 16 can sustain 16 simultaneous SSE streams before new connections queue. That is fine for a couple hundred active dashboard viewers on a couple of boxes and wrong for a chat app with 10k concurrent connections. Plan around it:
- Size Puma threads for the SSE cap you actually need (
threads 0, 64on a dedicated metrics box is defensible;64threads doing app work is not). - Keep the streaming action on a separate, dedicated route/process if it shares a box with request-heavy traffic — one stuck stream shouldn’t consume the thread that serves your API.
- If fan-out is huge or you need per-client inbox semantics, reach for Action Cable (Rails 7+/8+, Solid Cable) instead: it multiplexes many clients over shared connections and is the right tool the moment streams stop being unidirectional.
What we measure
With SSE wired in, the metrics box serves 400 simultaneous dashboard connections on two Puma processes while the app’s request QPS is unchanged — the polling traffic simply vanished. Client-to-first-frame latency is under 100ms (dominated by the publisher-to-Redis hop, not the stream), and the browser’s built-in reconnect absorbed every deploy without a reload. The failure mode to watch is a stalled stream: a connection that’s alive but not delivering because the producer died. The heartbeat frame is your liveness signal — alert when a subscription gets no ping for two intervals.
Production lessons
- Never write to the stream without an
ensure-based close. Every leaked stream is a thread, a socket, and usually a subscriber thread behind it. Teardown is the feature. - The proxy stack decides real-time or not. Disable buffering at every hop (nginx
X-Accel-Buffering: no, CDN equivalents) and setCache-Control: no-cache, or your “stream” is a batch job. - Heartbeat or be dropped. Idle connections get reaped by proxies and load balancers; a 15-30s ping frame keeps the path warm and doubles as a dead-producer detector.
- Know when SSE is the wrong tool. If clients need to send messages, or you need one logical connection per user across tabs, or fan-out exceeds the thread budget — that’s Action Cable territory. SSE wins precisely when the data is server-to-client-only.
For one-way push on a budget, SSE through ActionController::Live is the smallest thing that works: plain HTTP, browser-native reconnect, no new protocol, no new infrastructure — just a controller action that knows how to say goodbye.