How to use Rails ActionController::Live::SSE (server-sent events)

thilonel
3 min readNov 1, 2022

--

The official Rails API documentation doesn’t say much, but enough to get started. Let’s try to figure out how to use this!

Create the controller action as per the example in the guide and define the route.

> sse_controller.rbclass SseController < ActionController::Base
include ActionController
::Live

def index
response.headers['Content-Type'] = 'text/event-stream'
sse = SSE.new(response.stream, retry: 300, event: "event-name")
sse.write({ name: 'John'})
sse.write({ name: 'John'}, id: 10)
sse.write({ name: 'John'}, id: 10, event: "other-event")
sse.write({ name: 'John'}, id: 10, event: "other-event", retry: 500)
ensure
sse.close
end
end
> routes.rbresources :sse, only: [:index]

Now we can start calling it from the browser, see what happens. I’m following the official docs from Mozilla.

In a new tab I open the local Rails app localhost:3001 and then I open a browser console. In the console I’ll connect to this EventSource and I set handlers for the 3 different even types described by the docs.

source = new EventSource(‘/sse’);
source.onopen = (event) => {
console.log("The connection has been established.", event);
};
source.onmessage = (event) => {
console.log("EventSource message received:", event);
};
source.onerror = (err) => {
console.error("EventSource failed:", err);
};

Running this, you’ll start receiving a lot of errors. Don’t worry: source.close() will stop the madness.

Taking another look at our code, we can see that the server and client side events have nothing to do with each other. Let’s change the server side code to return events that we subscribe to.

def index
response.headers['Content-Type'] = 'text/event-stream'
sse = SSE.new(response.stream, retry: 300, event: "open")
sse.write({ name: 'John'}, event: "message")
sleep 2
sse.write({ name: 'John'}, id: 10, event: "message")
ensure
sse.close
end

After executing the same code from the console against this one, it looks much better! I got the something like this:

The connection has been established. Event {…}
EventSource message received: MessageEvent {isTrusted: true, data: '{"name":"John"}', origin: 'http://localhost:3001', lastEventId: '', source: null, …}
EventSource message received: MessageEvent {isTrusted: true, data: '{"name":"John"}', origin: 'http://localhost:3001', lastEventId: '10', source: null, …}
EventSource failed: Event {…}
The connection has been established. Event {…}
EventSource message received: MessageEvent {isTrusted: true, data: '{"name":"John"}', origin: 'http://localhost:3001', lastEventId: '', source: null, …}
EventSource message received: MessageEvent {isTrusted: true, data: '{"name":"John"}', origin: 'http://localhost:3001', lastEventId: '10', source: null, …}
EventSource failed: Event {…}

It’s worth going to the Network tab as well to check the request log. What you will notice there, is that despite the sleep 2 on the server side, all the messages are being delivered at the same time, then the request is just closed. That’s not really async and it really defeats the purpose.

Luckily, we are not the first ones with the issue, as you can see on this StackOverflow question. The culprit is a middleware called Rack::ETag which wants to buffer your response. It’s an issue reported on GitHub and the proposed solution is to use Rack 3, but as it’s not an option for me at the time of writing. The suggested workaround there is:

response.headers['Last-Modified'] = Time.now.httpdate

Yes, this fixes it. We receive the two messages with 2 seconds of delay, yay!

The next issue I noticed, is that if I source.close() before all the messages are delivered, it throws an error, so let’s handle that and see how the code will look like:

class SseController < ActionController::Base
include ActionController
::Live
def index
response.headers['Content-Type'] = 'text/event-stream'
response.headers['Last-Modified'] = Time.now.httpdate

sse = SSE.new(response.stream, retry: 300, event: "open")

5.times do
sleep 2
sse.write({ name: 'John'}, event: "message")
end
rescue ActionController::Live::ClientDisconnected
sse.close
ensure
sse.close
end
end

Perfect, I did source.close() after 3 messages and there were no issues on the server side reported.

Read my other post on how you can use Rails server-side events to fix responsiveness issues (when requests are queueing for too long)!

--

--

thilonel

Documenting the more interesting and hopefully useful bits of my journey as a software developer.