Modern applications often need to deliver information to the browser as it becomes available, rather than waiting until the entire controller action finishes.
Examples include:
- Live progress updates
- Long-running exports
- Real-time dashboards
- AI-generated responses
- Build/deployment logs
- Notifications
- Server-side status updates
- Streaming large files
- Server-Sent Events (SSE)
Rails provides this capability through ActionController::Live.
Rails 8.1 also exposes a particularly useful companion class:
ActionController::Live::SSE
Together, they provide a relatively simple way to implement HTTP streaming and Server-Sent Events directly from a Rails controller.
One important clarification: ActionController::Live itself is not new in Rails 8.1. It has existed for several Rails versions. However, Rails 8.1 continues to provide and refine the streaming infrastructure, including configuration around execution-state sharing. The examples below are based on the Rails 8.1 API.
What is ActionController::Live?
Normally, a Rails controller behaves conceptually like this:
Browser | | HTTP request vRails Controller | | execute entire action | | generate complete response vBrowser receives response
For example:
def report result = generate_report render json: resultend
The browser generally waits until the action has generated its response.
With ActionController::Live, Rails can instead stream pieces of the response while the action is still executing:
Browser | | HTTP request vRails Controller | | write chunk #1 |--------------------> Browser | | write chunk #2 |--------------------> Browser | | write chunk #3 |--------------------> Browser | | finish
Rails documents ActionController::Live as a module that allows controller actions to stream data to the client as it is written.
Basic ActionController::Live Example
A minimal controller looks like this:
class StreamsController < ApplicationController
include ActionController::Live
def show
response.headers["Content-Type"] = "text/plain"
5.times do |i|
response.stream.write "Chunk #{i + 1}\n"
sleep 1
end
ensure
response.stream.close
end
end
The important part is:
include ActionController::Live
and then:
response.stream.write(...)
Instead of constructing one large response, the controller writes directly to the response stream.
What happens internally?
Rails executes the streaming action in a separate thread so that the response can begin flowing to the client while the controller continues producing data. Rails 8.1 uses a dedicated cached thread-pool executor for live controller processing.
That distinction is extremely important for production applications.
What is Server-Sent Events?
ActionController::Live is the general streaming mechanism.
SSE is a specific protocol built on top of HTTP streaming.
Server-Sent Events allow the server to continuously send events to a browser over a long-lived HTTP connection.
The browser uses the standard JavaScript API:
const source = new EventSource("/events");source.onmessage = event => { console.log(event.data);};
The communication is one-way:
Server --------------------> Browser
Unlike WebSockets:
Server <-------------------> Browser
SSE is therefore a good choice when the browser mainly needs to listen for server-side updates rather than continuously send messages back to the server. The browser’s EventSource API maintains the persistent connection and automatically handles reconnection.
ActionController::Live::SSE
Rails provides:
ActionController::Live::SSE
to make SSE formatting easier.
Instead of manually writing:
event: updatedata: {"status":"processing"}
Rails can generate the SSE format for you.
The class accepts a stream:
sse = ActionController::Live::SSE.new(response.stream)
and then:
sse.write({ status: "processing" })
Rails converts non-string objects to JSON and writes them using SSE formatting.
Building a Rails SSE Endpoint
Let’s build a realistic example.
Controller
class NotificationsController < ApplicationController
include ActionController::Live
def index
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
sse = ActionController::Live::SSE.new(
response.stream,
retry: 3000
)
10.times do |i|
sse.write(
{
message: "Notification #{i + 1}",
timestamp: Time.current.iso8601
},
event: "notification",
id: i + 1
)
sleep 2
end
ensure
sse&.close
end
end
Rails’ SSE implementation supports three primary options:
:event:retry:id
event identifies the event type, retry tells the browser how long to wait before reconnecting, and id becomes the event identifier used for Last-Event-ID on reconnect.
JavaScript Client
The browser can consume the endpoint using EventSource.
const source = new EventSource("/notifications");source.addEventListener("notification", event => { const data = JSON.parse(event.data); console.log(data.message); console.log(data.timestamp);});source.onerror = error => { console.error("SSE connection error", error);};
The browser automatically opens a persistent HTTP connection.
When Rails sends:
event: notificationid: 1data: {"message":"Notification 1","timestamp":"..."}
the browser invokes:
source.addEventListener("notification", ...)
The SSE wire format is based on text fields such as event, data, id, and retry, with an empty line terminating each event.
ActionController::Live vs ActionController::Live::SSE
This distinction is worth remembering.
| Feature | ActionController::Live | ActionController::Live::SSE |
|---|---|---|
| Purpose | Generic HTTP streaming | SSE formatting |
| Output | Arbitrary stream data | SSE events |
| Browser API | Depends on your protocol | EventSource |
| JSON handling | You handle it | Rails can serialize objects |
| Event names | Manual | Built in |
| Event IDs | Manual | Built in |
| Reconnection support | Manual | SSE protocol support |
| Typical use | CSV/file/log streaming | Notifications/live updates |
Think of it like this:
ActionController::Live
|
+---- response.stream.write
|
+---- send_stream
|
+---- SSE
|
+---- event
+---- data
+---- id
+---- retry
Streaming a Large CSV
ActionController::Live is not limited to SSE.
A very practical use case is exporting a large dataset.
Rails 8.1 exposes send_stream, specifically for generating data progressively rather than buffering the entire file in memory.
For example:
class ReportsController < ApplicationController
include ActionController::Live
def export
send_stream(
filename: "users.csv",
type: "text/csv"
) do |stream|
stream.write "id,email,created_at\n"
User.find_each do |user|
stream.write(
"#{user.id},#{user.email},#{user.created_at.iso8601}\n"
)
end
end
end
end
This is much better than:
csv = User.find_each.map do |user|
...
end
send_data csv
for a very large export.
The second approach potentially builds a large amount of data in memory.
The streaming approach allows Rails to send the output progressively.
A Very Interesting Use Case: AI Streaming
Another practical use case is streaming generated text.
Imagine an AI API returns tokens incrementally:
HelloHello, IHello, I canHello, I can helpHello, I can help you...
Instead of waiting for the complete response:
response = ai_client.generate(...)render json: response
you could expose a streaming endpoint:
class AiController < ApplicationController
include ActionController::Live
def stream
response.headers["Content-Type"] = "text/event-stream"
sse = ActionController::Live::SSE.new(
response.stream,
event: "token"
)
ai_client.stream(prompt) do |token|
sse.write(
{
content: token
}
)
end
ensure
sse&.close
end
end
The browser can then update the UI immediately as chunks arrive.
This is one of the reasons HTTP streaming has become particularly relevant for modern applications.
Real-Time Notifications
A very common architecture is:
+----------------+
| Rails Server |
+--------+-------+
|
| SSE
|
+--------v-------+
| Browser |
+----------------+
For example:
class NotificationsController < ApplicationController
include ActionController::Live
def stream
response.headers["Content-Type"] = "text/event-stream"
sse = ActionController::Live::SSE.new(
response.stream,
event: "notification"
)
loop do
notification = Notification.pending.first
if notification
sse.write(
{
id: notification.id,
message: notification.message
},
id: notification.id
)
else
# Heartbeat
sse.write(": keep-alive")
end
sleep 2
end
ensure
sse&.close
end
end
However, this example introduces an important architectural question.
Where does the event come from?
Polling the database inside every open SSE request is usually not a scalable architecture.
For production systems, you will typically want an event source such as:
Database | vRedis / PubSub / Message Broker | vRails SSE endpoint | vBrowser
That is a much better design than repeatedly querying the database from every connected client.
Heartbeats Matter
Long-lived HTTP connections can be terminated by proxies, load balancers, or infrastructure when no data is transferred for a while.
SSE supports comment messages such as:
: heartbeat
which browsers ignore as application events but still receive as stream traffic.
The SSE format explicitly allows comment lines, and they can be used to keep connections alive.
In Rails:
sse.write(": heartbeat")
or, depending on how you implement the stream, write an SSE comment directly to response.stream.
For an application with long periods of inactivity, heartbeat strategy should be considered part of your production design.
Reconnection and Last-Event-ID
One of the most useful SSE features is event IDs.
Suppose Rails sends:
id: 101event: order_updatedata: {"status":"paid"}
The browser remembers the last event ID.
If the connection is interrupted, the browser may reconnect and send:
Last-Event-ID: 101
Rails’ SSE class supports the id field specifically for this scenario.
Your controller can inspect it:
last_id = request.headers["Last-Event-ID"]
and resume appropriately:
updates = OrderUpdate.where("id > ?", last_id.to_i)updates.find_each do |update| sse.write( update.attributes, id: update.id, event: "order_update" )end
This is significantly more robust than treating every reconnect as a completely new stream.
The Most Important ActionController::Live Caveat: Threads
This is probably the most important thing to understand before introducing ActionController::Live.
Rails executes the streaming action in a separate thread.
Therefore:
class MyController < ApplicationController include ActionController::Live def stream # Runs in streaming execution context endend
should not be treated exactly like a normal synchronous controller action.
Rails explicitly warns that streaming actions need to be thread-safe and should not share unsafe mutable state between threads.
Avoid patterns such as:
@@shared_state = {}@@shared_state[user_id] = ...
or other mutable global/class-level state unless it is deliberately designed for concurrent access.
Prefer:
RedisDatabaseMessage brokerThread-safe abstractions
for shared state.
Rails 8.1: Execution State Sharing
Rails 8.1 exposes:
config.action_controller.live_streaming_excluded_keys
which controls which execution-state keys should not be copied into the streaming thread.
By default, Rails shares execution state from the parent thread.
One important example involves Active Record connection routing.
Rails documents this configuration for cases such as:
ActiveRecord::Base.connected_to(role: :reading) do ...end
where the streaming thread might otherwise inherit the parent’s database connection context.
For example:
config.action_controller.live_streaming_excluded_keys = [:active_record_connected_to_stack]
This is a more advanced Rails 8.1 consideration, but it demonstrates an important point:
Streaming is not just “normal controller code with
response.stream.write.”
Execution context matters.
Headers Must Be Set Before Streaming
Once you start writing to the stream:
response.stream.write(...)
the response can be committed.
After the response is committed, you cannot safely modify headers.
Rails specifically documents that calling write or close commits the response.
Therefore do this:
response.headers["Content-Type"] = "text/event-stream"response.headers["Cache-Control"] = "no-cache"sse = ActionController::Live::SSE.new(response.stream)sse.write(...)
Not:
sse.write(...)response.headers["Cache-Control"] = "no-cache"
The second version is too late.
Always Close the Stream
This is another critical rule.
Always ensure the stream closes:
ensure sse&.closeend
or:
ensure response.stream.closeend
Rails explicitly warns that failing to close the stream can leave the socket open indefinitely.
A production implementation should therefore almost always look like:
begin # streaming workensure # close streamend
Handling Client Disconnects
A browser can disappear at any time.
For example:
User closes tab | vSSE connection disappears | vRails stream encounters disconnect
Rails exposes:
ActionController::Live::ClientDisconnected
for client disconnect situations.
You can handle it explicitly when appropriate:
rescue ActionController::Live::ClientDisconnected Rails.logger.info("SSE client disconnected")ensure sse&.closeend
For long-running streams, disconnect handling is especially important because you don’t want server-side work continuing unnecessarily after the browser is gone.
Proxy and Middleware Buffering
A common mistake is to test streaming locally and assume production will behave identically.
You might write:
response.stream.write "hello"sleep 5response.stream.write "world"
and expect:
hello
to appear immediately.
But an intermediary could buffer the response.
Possible intermediaries include:
Browser |Load Balancer |Reverse Proxy |Nginx |Rails
Rails itself documents that response buffering can interfere with streaming, including interaction with Rack::ETag in relevant Rack versions.
Therefore streaming should always be tested through the same infrastructure path used in production.
SSE vs WebSockets vs Polling
This is one of the most important architectural decisions.
| Approach | Direction | Connection | Good For |
|---|---|---|---|
| Polling | Client → Server repeatedly | Short | Simple updates |
| Long Polling | Mostly server → client | Repeated HTTP | Older architectures |
| SSE | Server → Client | Long-lived HTTP | Notifications/live feeds |
| WebSocket | Bidirectional | Persistent socket | Chat/games/collaboration |
ActionController::Live | Depends on implementation | Streaming HTTP | Generic streaming |
Use SSE when:
Server -> Browser
is the dominant requirement.
Examples:
Order statusBuild progressNotificationsStock updatesLive dashboardAI text streamingImport progress
Use WebSockets when:
Server <-> Browser
needs continuous two-way communication.
Examples:
ChatMultiplayer applicationsCollaborative editingInteractive sessions
Use normal HTTP when:
You simply need:
request -> response
There is no reason to introduce streaming complexity for an ordinary CRUD endpoint.
Connection Scalability Is Different
A normal HTTP request may live for:
100 ms500 ms2 seconds
An SSE connection may remain open for:
5 minutes30 minutesseveral hours
That changes your capacity model.
Suppose:
10,000 users
each maintain an SSE connection.
That means your infrastructure potentially needs to support:
10,000 long-lived connections
You therefore need to think about:
Web server capacityWorker/thread usageFile descriptorsLoad balancersReverse proxiesTimeout configurationConnection limitsMemoryMonitoring
There is also a browser-level consideration: SSE uses persistent HTTP connections, and connection limits can matter especially under HTTP/1.1; HTTP/2 changes the connection model by multiplexing streams.
Be Careful with Active Record Connections
A particularly important Rails concern is database connection usage.
Consider:
loop do users = User.where(active: true) ... sleep 1end
inside every SSE request.
If you have hundreds or thousands of clients, you can easily end up with poor database behavior.
A better architecture is usually:
Event Producer
|
+---------+---------+
| |
Redis Broker
| |
+---------+---------+
|
Rails SSE
|
Browser
The SSE request should ideally wait for events, rather than continuously hammer the database.
A Better Production Architecture
For example, imagine an order-management application.
When an order changes:
Order updated | vPublish "order.updated" | vRedis / PubSub | vSSE connection | vBrowser updates UI
The Rails controller becomes primarily responsible for:
Connection ↓Subscribe ↓Receive event ↓Serialize event ↓Write SSE ↓Repeat
rather than:
Connection ↓Query database ↓Sleep ↓Query database ↓Sleep ↓Query database
That distinction becomes very important at scale.
Testing an SSE Endpoint
A browser test is useful, but curl is often even more convenient during development.
For example:
curl -N http://localhost:3000/notifications
The -N option prevents curl from buffering output, making the stream easier to observe.
You should see events arrive progressively:
event: notificationid: 1data: {"message":"Notification 1"}event: notificationid: 2data: {"message":"Notification 2"}
This is a very useful debugging technique.
Testing ActionController::Live
For controller tests, streaming requires more consideration than a typical controller action because the response is not necessarily generated as one complete body.
The key things to test are:
Content-TypeEvent namesEvent IDsPayload formatConnection terminationClient disconnect handlingError handling
For example, conceptually:
assert_equal "text/event-stream", response.media_type
and verify that the generated body contains expected SSE fields.
For more complex streaming behavior, integration/system-level testing is generally more valuable than testing only internal controller implementation details.
A Clean SSE Controller Pattern
For a production-style controller, I prefer keeping the controller small:
class EventsController < ApplicationController
include ActionController::Live
def index
prepare_stream_headers
sse = ActionController::Live::SSE.new(
response.stream,
retry: 3_000
)
event_stream.each do |event|
sse.write(
event.payload,
event: event.type,
id: event.id
)
end
rescue ActionController::Live::ClientDisconnected
Rails.logger.info("SSE client disconnected")
ensure
sse&.close
end
private
def prepare_stream_headers
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
end
def event_stream
# Redis / PubSub / broker subscription
end
end
The controller handles HTTP concerns, while the event source is delegated elsewhere.
That separation becomes especially valuable when the event system grows.
Advantages of ActionController::Live
Lower time-to-first-byte
The server can start sending data before the complete operation has finished.
Lower memory usage for large streams
You don’t necessarily need to construct the entire response in memory first.
Native HTTP
There is no requirement for a completely different networking protocol.
SSE is simple for browser clients
The browser already provides:
EventSource
Automatic SSE reconnect behavior
SSE includes protocol support for reconnecting and event IDs.
Fits naturally into Rails controllers
You can continue using Rails authentication, routing, controllers, and application services while introducing streaming only where needed.
Disadvantages
Streaming is not free.
Threading complexity
ActionController::Live executes the action in a separate thread.
Long-lived connections
Unlike conventional requests, connections may remain open for long periods.
Capacity planning becomes important
Thousands of connected browsers can have a very different infrastructure impact than thousands of short requests.
Reverse-proxy configuration matters
Buffering and timeout behavior can break an otherwise-correct implementation.
Database usage can become dangerous
Naive polling inside every streaming connection can put significant pressure on PostgreSQL.
Operational complexity
Logging, monitoring, disconnects, reconnects, retries, and infrastructure timeouts all become part of the design.
When Should a Rails Developer Use It?
A good decision rule is:
Do I need data before the complete response is available?
|
Yes
|
v
Does the client only need server -> browser updates?
|
+--+--+
| |
Yes No
| |
v v
SSE WebSocket
For generic data/file streaming:
ActionController::Live
For browser-facing event streams:
ActionController::Live::SSE
For ordinary request/response APIs:
render json:
is usually the better choice.
What a Senior Rails Developer Should Know Before Using It
Before introducing ActionController::Live, I would explicitly answer these questions:
1. How long will the connection remain open?
Seconds?
Minutes?
Hours?
2. How many simultaneous clients could exist?
100?
1,000?
100,000?
3. What is the event source?
Database?
Redis?
Kafka?
Another service?
4. What happens when the client disconnects?
Can the server stop work immediately?
5. How will reconnects work?
Will events be lost?
Do you need id and Last-Event-ID?
6. What happens behind the load balancer?
Does it buffer?
Does it timeout idle connections?
7. Is your code thread-safe?
Remember that Rails executes Live actions in a separate thread.
8. How will you monitor connections?
You should be able to answer:
How many active SSE connections exist?How long have they been open?How many disconnected unexpectedly?How many events are being delivered?What is the event delivery latency?
Final Example
A compact Rails 8.1 SSE implementation can look like this:
class EventsController < ApplicationController
include ActionController::Live
def stream
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
sse = ActionController::Live::SSE.new(
response.stream,
retry: 3_000
)
10.times do |i|
sse.write(
{
message: "Event #{i + 1}",
timestamp: Time.current.iso8601
},
event: "update",
id: i + 1
)
sleep 1
end
rescue ActionController::Live::ClientDisconnected
Rails.logger.info("Client disconnected")
ensure
sse&.close
end
end
And the client:
const events = new EventSource("/events/stream");events.addEventListener("update", event => { const data = JSON.parse(event.data); console.log(data.message);});events.onerror = error => { console.error("Connection error", error);};
This small example demonstrates the complete concept:
ActionController::Live ↓HTTP streaming ↓ActionController::Live::SSE ↓text/event-stream ↓Browser EventSource ↓Real-time UI updates
Conclusion
ActionController::Live is Rails’ low-level mechanism for streaming HTTP responses while the controller is still producing them.
ActionController::Live::SSE builds on that mechanism to provide a convenient implementation of Server-Sent Events.
The most important distinction is:
Live = streaming mechanismSSE = event-stream protocol
For modern Rails applications, this makes ActionController::Live particularly useful for large exports, progressive responses, logs, long-running operations, and AI output, while SSE is a strong fit for server-to-browser real-time updates.
But the real engineering challenge is usually not writing:
sse.write(...)
The difficult part is designing the surrounding system correctly:
Event source ↓Concurrency ↓Connection lifecycle ↓Reconnect strategy ↓Proxy/load-balancer behavior ↓Database/resource usage ↓Observability
That is where ActionController::Live moves from being a simple Rails API feature to a genuine production architecture decision.
References
Rails 8.1 ActionController::Live API: https://edgeapi.rubyonrails.org/classes/ActionController/Live.html
Rails 8.1 ActionController::Live::SSE API: https://api.rubyonrails.org/classes/ActionController/Live/SSE.html
Rails 8.1 release information:https://guides.rubyonrails.org/8_1_release_notes.html
MDN – Server-Sent Events and EventSource:
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
https://developer.mozilla.org/en-US/docs/Web/API/EventSource
Happy Implementing!