
This post is part of the A Deep Dive Into Rack for Ruby series
- 1The Basics of Rack for Ruby
- 2Rack for Ruby: Socket Hijacking
- 3Server-sent Events and WebSockets in Rack for Ruby
In the previous part of this series, we discovered how to create persistent connections in Rack in theory, but now we'll put what we learned into practice.
The web has two formalized specifications for communication over a persistent connection: server-sent events (SSEs) and WebSockets.
WebSockets are widely used and highly popular, but SSEs are far less well-known. Let's explore them first.
Server-sent Events
Server-sent events (SSEs) enable a client to hold an open connection with the server, but only the server can publish messages to the client. It isn't a bi-directional protocol.
SSEs are a JavaScript API, so let's modify our app to serve an HTML page with the required script:
class App
def call(env)
req = Rack::Request.new(env)
path = req.path_info
case path
when "/"
sse_js(env)
end
end
private
def sse_js(env)
body = <<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SSE - Demo</title>
<script type="text/javascript">
const eventSource = new EventSource("/sse")
eventSource.addEventListener("message", event => {
document.body.insertAdjacentHTML(
"beforeend",
`<p>${event.data}</p>`
)
})
</script>
</head>
<body>
</body>
</html>
HTML
[200, { "content-type" => "text/html" }, [body]]
end
endThe API is encapsulated in the EventSource class and new messages from the server trigger events that we listen for. Next, we need to build the endpoint that sends the events:
class App
def call(env)
req = Rack::Request.new(env)
path = req.path_info
case path
when "/"
sse_js(env)
when "/sse"
sse(env)
end
end
private
def sse_js(env)
# ...
end
def sse(env)
body = proc do |stream|
Thread.new do
5.times do
stream.write "data: #{Time.now}!\n\n"
sleep 1
end
ensure
stream.close
end
end
[200, { "content-type" => "text/event-stream" }, body]
end
endFrom a server point of view, this is fairly similar to the streaming bodies example we used in the previous part of this series. It's worth noting the content-type header and the string format written back to the client.
Run the server (make sure you switch back to Puma):
$ bundle exec pumaOpen up localhost:9292 on your web browser, and you'll see the time written to the document five times at one-second intervals.
This technique is great when the server just needs to notify the client about updates. The above example is fairly contrived, though, as it uses a loop, so let's look at how we can use this technique in a real application.
Ruby Queues
Ruby provides a Queue data structure for communication between threads. We can use that to publish data back to a client. Let's stick with the same use case of publishing the current time five times at one-second intervals, but now we'll publish from a background thread.
class App
def call(env)
# ...
end
private
def sse_js(env)
# ...
end
def sse(env)
queue = Thread::Queue.new
trigger_background_loop(queue)
body = proc do |stream|
Thread.new do
loop do
data = queue.pop
stream.write "data: #{data}!\n\n"
end
ensure
stream.close
end
end
[200, { "content-type" => "text/event-stream" }, body]
end
def trigger_background_loop(queue)
Thread.new do
5.times do
queue.push(Time.now)
sleep 1
end
end
end
endIn the above example, we spawn another background thread to push the current time to the queue every second. In the SSE thread, we call queue.pop, which blocks until something is added to the queue.
Using this technique, we can use a pub/sub system such as Redis to add data to the queue from a background thread, which is then published to the client.
That's SSEs covered! Next, let's look at WebSockets.
WebSockets
WebSockets are a bi-directional, full-duplex communication protocol that supports both binary and text data for client-server communication. They're widely used in the modern web and underpin Rails' Action Cable framework.
A WebSocket is created using an HTTP connection, but as a protocol, it's completely independent of HTTP.
To create a WebSocket connection, the client must make an HTTP request with these headers:
Connection: Upgrade
Upgrade: websocketThe server will respond with the status 101, meaning Switching Protocols. The TCP connection used for the HTTP request is upgraded to a WebSocket connection.
We won't get into the nitty-gritty of the WebSocket protocol in this post. It's fairly fiddly since it's a binary protocol. If you're curious, Starr Horne has written an amazing article on WebSockets.
Let's look at how to upgrade a TCP socket to a WebSocket connection.
Upgrading from HTTP to WebSockets
As described above, we'll need to send a 101 response. After this, we'll write to the socket using WebSockets' binary protocol for the communication to work.
require 'digest/sha1'
class App
def call(env)
req = Rack::Request.new(env)
key = req.get_header("HTTP_SEC_WEBSOCKET_KEY")
response_key = Digest::SHA1.base64digest([key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
body = proc do |stream|
response = "Hello world!"
output = [0b10000001, response.size, response]
stream.write output.pack("CCA#{ response.size }")
ensure
stream.close
end
[101, { "Upgrade" => "websocket", "Connection" => 'upgrade', "Sec-WebSocket-Accept" => response_key }, body]
end
endWe have to create a response key to securely create the connection. The UUID used to generate it is a global constant found in the specification. We won't go into the binary format of the string we're writing into the WebSocket connection, but it's all described in 'Building a simple websockets server from scratch in Ruby' by Starr Horne if you're curious.
Demo
Run the server:
$ bundle exec pumaThe easiest way to create a connection is to use a WebSocket client. I recommend websocat.
$ websocat ws://127.0.0.1:9292/You'll see the string Hello world! printed out! The connection is now active. In theory, we can write and receive messages over this socket now. We still need to implement receiving or publishing messages on the server for it to work in practice, but that's for another post.
A final word of warning: always remember that persistent connections come with challenges when using a threaded web server like Puma. A persistent connection ties up a thread and can cause significant performance issues unless you open a sizeable can of worms to implement your own threading mechanism.
Wrapping Up
That concludes our three-part deep dive into Rack! We first looked at how to set up a basic Rack app, before diving into socket hijacking for persistent connections.
Lastly, in this part, we used two specifications provided by our web platform to communicate over persistent connections: server-sent events (SSEs) and WebSockets.
I hope you've found this series useful. Happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
This post is part of the A Deep Dive Into Rack for Ruby series
- 1The Basics of Rack for Ruby
- 2Rack for Ruby: Socket Hijacking
- 3Server-sent Events and WebSockets in Rack for Ruby
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Ayush Newatia
Guest author Ayush is a freelance Ruby and Rails developer. He's the author of The Rails and Hotwire Codex and part of the Bridgetown core team. He also runs a privacy focused mailing list app called Scattergun.
All articles by Ayush NewatiaBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


