Rack 3 introduced support for input and output streaming. This is a powerful feature that improves both performance and provides opportunities for enhanced interactivity in web applications. We will explore how to use streaming input and output in Rack, including server-sent events, streaming templates, streaming uploads, and bi-directional streaming. We will also discuss how to use these features in Ruby on Rails.

All the source code discussed in this article is available on GitHub. I recommend you check it out so you can follow along with the examples and try it out for yourself.
Streaming Output
Streaming output is a feature that allows you to send data to the client as it is generated. This is useful for sending large amounts of data, such as video or audio streams, or for sending data that is generated over time, such as a log file or a real-time feed. Streaming output is supported in Rack by returning a callable object as the response body. This callable object will get invoked with a stream argument, which you can read and write to from within the block.
The following example shows how to emit the current date and time in CSV format:
require 'csv'
run do |env|
request = Rack::Request.new(env)
body = proc do |stream|
csv = CSV.new(stream)
csv << ['Date', 'Time']
while true
now = Time.now
csv << [now.strftime('%Y-%m-%d'), now.strftime('%H:%M:%S')]
sleep(1)
end
end
[200, {'content-type' => 'text/csv'}, body]
end
You can use the curl
command to test the streaming output:
$ curl --insecure https://localhost:9292
Date,Time
2024-11-11,16:58:13
2024-11-11,16:58:14
2024-11-11,16:58:15
2024-11-11,16:58:16
2024-11-11,16:58:17
^C
Use cases for streaming CSV output include:
- Large Report Exports
- Users can download extensive datasets as they're being processed, reducing wait times and enhancing user experience.
- Financial Data Feeds
- Deliver real-time transaction or portfolio data in CSV format for continuous access by analysts and clients.
- Log Monitoring
- Stream application or server logs in CSV format for immediate analysis and rapid response to issues.
- Real-Time Business Intelligence
- Stream CSV data from sources like CRMs or IoT devices for up-to-date business intelligence and reporting.
Streaming Server-Sent Events (SSE)
Streaming output can be a bit tricky to use in web browsers, as they may buffer the response. One way to work around this is to use Server-Sent Events (SSE), which are specifically handled by web browsers and are guaranteed to be unbuffered.
The following example shows how to use streaming output with SSE:
def server_sent_events?(env)
env['HTTP_ACCEPT'].include?('text/event-stream')
end
run do |env|
if server_sent_events?(env)
body = proc do |stream|
while true
stream << "data: The time is #{Time.now}\n\n"
sleep 1
end
rescue => error
ensure
stream.close(error)
end
[200, {'content-type' => 'text/event-stream'}, body]
else
# Else the request is for the index page, return the contents of index.html:
[200, {'content-type' => 'text/html'}, [File.read('index.html')]]
end
end
Of note, is the error handling. It's important to be aware of the various failure cases that can occur when streaming data. In the above example, we stream data indefinitely. If there is a failure for some reason, we will close the stream with the error. Closing the stream with an error will communicate to the client that the stream was terminated prematurely.
On the client side, you'll need to use the EventSource interface to receive the events. Here's an example of how you might do that:
<!doctype html>
<html>
<head>
<title>Server-Sent Events</title>
</head>
<body>
<h1>SSE Messages</h1>
<ul id="messages"></ul>
<script type="text/javascript">
const history = document.getElementById('messages');
const eventSource = new EventSource("events");
eventSource.onmessage = (event) => {
const container = document.createElement("li");
container.innerText = event.data;
messages.appendChild(container);
messages.scrollTop = messages.scrollHeight;
};
</script>
</body>
</html>
Event streams are easy to use - they are supported by all modern browsers and handle reconnections and other edge cases automatically. This makes them a great choice for streaming data to the client.
Use cases for streaming SSE include:
- Live Notifications
- Push real-time notifications for events like promotions, user activity updates, or order status changes to keep users informed instantly.
- Real-Time Dashboards
- Stream continuous updates to data visualization dashboards for monitoring systems, sales metrics, or analytics without manual refreshes.
- Live Polls and Q&A
- Stream responses and results for interactive polls, Q&A sessions, or audience participation during live events and webinars.
- Chat and Messaging Apps
- Deliver new messages instantly as they arrive, creating a more responsive chat experience for users.
Streaming Templates
Most web frameworks use templates to generate HTML. These templates are typically rendered in full before being sent to the client. However, in some cases it's possible to stream the template as it is being generated. This greatly improves the time to first byte, at the expense of slightly more complex error handling. By allowing the server to send chunks from the template as they are generated, the client can start rendering the page sooner. This is particularly useful for large pages, or pages with complex logic that takes time to render.
Here is an example of a template with sleep statements to simulate a slow rendering page:
<!doctype html><html><head><title>Green Bottles Song</title></head><body>
<?r self[:count].downto(1) do |i| ?>
<p>#{i} green bottles hanging on the wall,</br>
<?r sleep 1 ?>#{i} green bottles hanging on the wall,</br>
<?r sleep 1 ?>and if one green bottle should accidentally fall,</br>
<?r sleep 1 ?>there'll be #{(i-1)} green bottles hanging on the wall.</br>
<?r sleep 1 ?>
</p>
<?r end ?>
</body></html>
The following example shows how to use streaming templates with Falcon:
require 'xrb/template'
TEMPLATE = XRB::Template.load_file("template.xrb")
run do |env|
scope = {
count: env['QUERY_STRING'].then do |query_string|
query_string.empty? ? 10 : Integer(query_string)
end
}
[200, {"content-type" => "text/html"}, TEMPLATE.to_proc(scope)]
end
You can also do this with ERB templates, but you need to use a streaming-compatible renderer. Rails does not fully support this right now, but we are working on it!
Streaming Input
Streaming input is a feature that allows you to read data from the client as it is received. This is useful for processing large amounts of data, such as file uploads, or for processing data that is generated over time, such as a real-time feed. Streaming input is supported in Rack by reading from the input stream provided by the request environment.
The following example shows how to read a file upload from the client and process it chunk by chunk to generate a chuecksum:
require 'digest'
run do |env|
input = env['rack.input']
checksum = Digest::SHA256.new
# Read each chunk at a time, to avoid buffering the entire file in memory:
input.each do |chunk|
checksum.update(chunk)
end
[200, {'content-type' => 'text/csv'}, [checksum.hexdigest]]
end
If you upload a file (even a large one), it will be sent in chunks, and the checksum will be computed without buffering the entire input:
$ curl --insecure -X POST --data-binary @config.ru https://localhost:9292
f97a5502301d41c3ca77599468e40d186e4d245f83c925e08f731e8276b008a0
Bi-directional Streaming
Full bi-directional streaming is a useful feature for building interactive applications. By combining streaming input and streaming output, you can read and write data to the client in real-time.
The following example shows how to implement a simple chat server using bi-directional streaming:
require 'async/redis'
client = Async::Redis::Client.new
run do |env|
body = proc do |stream|
subscription_task = Async do
# Subscribe to the redis channel and forward messages to the client:
client.subscribe("chat") do |context|
context.each do |type, name, message|
stream.write(message)
end
end
end
stream.each do |message|
# Read messages from the client and publish them to the redis channel:
client.publish("chat", message)
end
rescue => error
ensure
subscription_task&.stop
stream.close(error)
end
[200, {'content-type' => 'text/plain'}, body]
end
Very few clients (e.g. curl) support bi-directional streaming, so we implemented a client to go with the example:
#!/usr/bin/env ruby
require 'async/http'
url = ARGV.pop || "https://localhost:9292"
endpoint = Async::HTTP::Endpoint.parse(url)
Async do |task|
client = Async::HTTP::Client.new(endpoint)
body = Protocol::HTTP::Body::Writable.new
input_task = task.async do
while line = $stdin.gets
body.write(line)
end
ensure
body.close_write
end
response = client.post("/", body: body)
response.each do |chunk|
$stdout.write("> #{chunk}")
end
ensure
input_task&.stop
response&.close
end
Due to the limitations of web browser implementations, bi-directional streaming is not well-supported in modern interfaces like fetch. However, it is still useful for scenarios that don't involve a browser, or for wrapping higher level protocols:
- Real-Time Transcription & Translation
- Stream audio data to a server for real-time transcription and translation, and stream the results back to the client for immediate feedback.
- Efficient HTTP RPC
- Some RPC protocols, like gRPC, can be implemented over HTTP using bi-directional streaming for efficient communication between clients and servers.
- WebSockets
- In Rack, WebSockets can and are implemented using bi-directional streaming.
Bi-directional WebSockets
To work around the limitations of bi-directional streaming in web browsers, you can use WebSockets. WebSockets provide a full-duplex communication channel over an HTTP connection. This reduces the difficulty of implementing real-time communication in web browsers.
The following example shows how to use WebSockets with Falcon:
require "async/websocket/adapters/rack"
run do |env|
if Async::WebSocket::Adapters::Rack.websocket?(env)
Async::WebSocket::Adapters::Rack.open(env) do |connection|
while true
connection.write("The time is #{Time.now}")
connection.flush
sleep 1
end
end
else
# Else the request is for the index page, return the contents of index.html:
[200, {'content-type' => 'text/html'}, [File.read('index.html')]]
end
end
On the client side, you can use the WebSocket API to send and receive messages. Here's an example of how you might do that:
<!doctype html>
<html>
<head>
<title>WebSockets</title>
</head>
<body>
<h1>WebSocket Messages</h1>
<ul id="messages"></ul>
<script type="text/javascript">
const messages = document.getElementById('messages');
const url = window.location.href.replace('http', 'ws');
const webSocket = new WebSocket(url);
webSocket.onmessage = (event) => {
const container = document.createElement('li');
container.innerText = event.data;
messages.appendChild(container);
messages.scrollTop = messages.scrollHeight;
};
</script>
</body>
</html>
Use cases for WebSockets include:
- Progressive Enhancement
- Enhance traditional web applications with incremental updates and real-time features for a more interactive user experience.
- Live Collaboration
- Support real-time editing and collaboration on documents, spreadsheets, or codebases for remote teams.
- Interactive Games
- Facilitate real-time multiplayer gaming experiences with features like chat, leaderboards, and live updates.
What about Rails?
Rails builds on top of Rack, so most of the examples above work unchanged, provided you use self.response = Rack::Response[...]
in the controller action, for example, the server-sent events example can be done like this:
class MyController
def streaming_sse
body = proc do |stream|
while true
stream << "data: The time is #{Time.now}\n\n"
sleep 1
end
rescue => error
ensure
stream.close(error)
end
self.response = Rack::Response[200, {"content-type" => "text/event-stream"}, body]
end
end
Of course, for the best performance, you should be running on top of Falcon. You can also check out falcon-rails-example for the above examples (and more) adapted to Rails. You might also like to check out my previous article on interactive Rails applications.
RubyConf AU 2023 Presentation
In this talk, I discuss asynchronous execution models in Ruby, and how we can build streaming applications using Rack and Falcon. I discuss some of the above approaches in the context of Ruby on Rails.
You can find the slides for my presentation here.
Conclusion
Rack 3 introduces powerful streaming features that improve performance and provide opportunities for enhanced interactivity in web applications. By leveraging streaming input and output, you can build applications that are more responsive and efficient, delivering real-time experiences that were previously challenging to achieve. By making full-duplex streaming easy to use in Rack and Falcon, we aim to position Ruby and Rails as an exceptional choice for building modern, interactive applications that require high performance and real-time capabilities.