Rails as client or relay
A Rails backend can participate in Server-Sent Events in two different ways. It can stream events to a browser, or it can consume an upstream event stream and relay selected data to its own clients.
The relay case is easy to underestimate. It is not only an HTTP request. It is a long-lived read loop with parsing, timeouts, cancellation, backpressure, and response-shaping decisions.
That code belongs near the boundary of the system, not scattered through controllers or jobs. The rest of the app should receive parsed domain events, not raw event-stream lines.
The event-stream format
SSE is line-oriented. Fields such as event:, data:, id:, and retry: are grouped until a blank line completes the event. Multiple data lines are joined with newlines.
A client implementation should parse the stream as a stream, not by assuming each network chunk is a complete event. TCP chunking and HTTP buffering do not respect application message boundaries.
The parser should also tolerate comments and heartbeat lines. Those are normal in long-lived SSE connections and often keep infrastructure from treating the connection as idle.
Timeouts and cancellation
A backend relay needs explicit timeouts. There is usually a connect timeout, a read timeout or heartbeat expectation, and a stream lifecycle rule for shutting the response down. Without these, a stuck upstream can leave server resources hanging.
Cancellation matters as much as timeout. If the downstream client disconnects, Rails should stop reading from the upstream stream. Otherwise the app becomes a data pump to nowhere.
ActionController::Live can stream from Rails, but it introduces thread and resource concerns. Treat each live response as an owned resource that must be closed in all paths.
Backpressure
If the upstream sends faster than the downstream can receive, the relay needs a policy. Buffering indefinitely is not a policy. It is a memory leak with better intentions.
Possible policies include dropping intermediate progress events, keeping only the latest state, closing slow clients, or moving event intake into a queue with bounded capacity. The right answer depends on whether events are facts, progress hints, or replaceable state snapshots.
That distinction should be visible in code. A payment event and a typing indicator are not allowed to have the same loss policy.
Safe response handling
The safest Rails shape is to isolate the stream reader, parse events into plain objects, and expose a small interface to controllers or jobs. The controller should not know the details of line buffering or upstream retry behavior.
Logging should include connection start, first event, heartbeat gaps, upstream close, downstream close, and parse errors. Most SSE bugs are timing bugs, and timing bugs need event logs rather than only exception traces.
Finally, every relay should have a non-streaming story. A status endpoint, cursor-based poll, or replay API makes recovery possible when the stream is interrupted.
Testing the relay
A useful test suite includes complete events, split events across chunks, comments, heartbeats, malformed lines, upstream disconnects, and downstream cancellation. The parser should be tested without a real network, then the integration path should be tested with a slow stream.
Production testing should also watch resource use. A relay that works for one client can still fail at twenty clients if each connection owns a thread, keeps large buffers, or ignores disconnects. Streaming correctness and capacity are separate checks.
Controller shape
A Rails SSE endpoint should be explicit about headers, cleanup, and the fact that a live response ties up resources for longer than a normal action.
class JobEventsController < ApplicationController
include ActionController::Live
def show
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
sse = SSE.new(response.stream)
JobEventFeed.each(job_id: params[:id]) do |event|
sse.write(event.payload, id: event.id, event: event.kind)
end
rescue IOError
# Client disconnected.
ensure
sse&.close
end
end
The code is only half the design. The feed needs a heartbeat, a stop condition, and a reconnection story through Last-Event-ID or an explicit cursor. Without that, a dropped connection becomes a correctness bug instead of a normal network event.
Back pressure and cleanup
Long-lived responses need a cleanup story. A disconnected browser should not leave a thread waiting forever, a database cursor open, or a subscription object retained in memory. The server should treat disconnects as normal control flow and release resources quickly.
Back pressure is the other half. If events are produced faster than the client can receive them, the app needs a policy: drop intermediate progress updates, batch them, close the stream, or ask the client to resume from a cursor. Without that policy, streaming code can become an unbounded queue hidden inside a controller.
These policies should be visible in code rather than implied by loops. A named buffer limit, heartbeat interval, and resume cursor make the endpoint reviewable. Hidden infinite streams are hard to operate because nobody can tell what normal pressure looks like.
Sources
- MDN, Using server-sent events. https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- Rails API, ActionController::Live. https://api.rubyonrails.org/classes/ActionController/Live.html
- HTML Living Standard, server-sent events. https://html.spec.whatwg.org/multipage/server-sent-events.html