Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

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, 64 on a dedicated metrics box is defensible; 64 threads 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 set Cache-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.

comments powered by Disqus