ruby javascript 112 lines · 3 tabs

Stream Server-Sent Events from a Sinatra Route With Keep-Alive Pings

Shared by codesnips Aug 2026
3 tabs
require 'set'
require 'json'

class Broadcaster
  def initialize
    @mutex = Mutex.new
    @connections = Set.new
  end

  def subscribe(stream)
    @mutex.synchronize { @connections << stream }
    lambda { @mutex.synchronize { @connections.delete(stream) } }
  end

  def publish(event:, data:, id: nil)
    frame = format_sse(event: event, data: data, id: id)
    dead = []
    @mutex.synchronize do
      @connections.each do |stream|
        begin
          stream << frame
        rescue IOError, Errno::EPIPE
          dead << stream
        end
      end
      dead.each { |s| @connections.delete(s) }
    end
  end

  def size
    @mutex.synchronize { @connections.size }
  end

  private

  def format_sse(event:, data:, id: nil)
    lines = []
    lines << "id: #{id}" if id
    lines << "event: #{event}"
    payload = data.is_a?(String) ? data : JSON.generate(data)
    payload.each_line { |line| lines << "data: #{line.chomp}" }
    lines.join("\n") + "\n\n"
  end
end
3 files · ruby, javascript Explain with highlit

Server-Sent Events (SSE) is a one-way streaming protocol where the server holds an HTTP connection open and pushes text/event-stream frames to the browser's EventSource. Unlike WebSockets it needs no protocol upgrade, rides over plain HTTP, and reconnects automatically — which makes it a good fit for dashboards, notifications, and log tailing. The challenge in a threaded Rack server is keeping that connection alive without leaking threads, and delivering messages to the right subscribers.

The Broadcaster tab is a tiny in-process pub/sub hub. It keeps a Set of open connection objects guarded by a Mutex, because Sinatra serves each request on its own thread and the subscriber set is shared mutable state. subscribe registers a connection and returns a teardown lambda; publish serializes a payload once as an SSE frame and fans it out, dropping any connection that raises on write (a client that has gone away). Formatting lives in format_sse, which emits the event:, data:, and optional id: fields followed by the blank line that terminates a frame.

The app.rb tab wires this into Sinatra using stream(:keep_open). That variant hands back a stream object and does NOT close it when the block returns, so the response body stays open for out-of-band writes. The route sets Content-Type: text/event-stream, disables buffering with X-Accel-Buffering: no (important behind nginx), and registers the stream with the Broadcaster. A background Thread sends a comment line : every fifteen seconds — a heartbeat that keeps proxies and load balancers from reaping an idle connection. The crucial detail is stream.callback: when the client disconnects, Sinatra invokes it, letting the code unsubscribe and kill the ping thread so nothing leaks. A separate POST /publish route lets any producer inject an event, and retry: on the initial frame tells the browser how long to wait before reconnecting.

The sse-client.js tab shows the consumer: new EventSource('/events') with addEventListener handlers per event name, plus an onerror hook. Because EventSource reconnects on its own and replays from Last-Event-ID, the server should treat id: as a resumable cursor. The main trade-off is that this hub is single-process; scaling across workers requires an external bus like Redis pub/sub in place of the in-memory Set.


Related snips

Share this code

Here's the card — post it anywhere.

Stream Server-Sent Events from a Sinatra Route With Keep-Alive Pings — share card
Link copied